longcelot-sheet-db
Version:
Google Sheets-backed staging database adapter for Node.js with schema-first design
383 lines • 17.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SheetAdapter = void 0;
exports.createSheetAdapter = createSheetAdapter;
const sheetClient_1 = require("./sheetClient");
const crud_1 = require("./crud");
const accessControl_1 = require("./accessControl");
const DEFAULT_HEADER_COLOR = '#E8F0FE';
const PermissionError_1 = require("../errors/PermissionError");
const SchemaError_1 = require("../errors/SchemaError");
const SchemaMismatchError_1 = require("../errors/SchemaMismatchError");
const schemaHash_1 = require("../utils/schemaHash");
const validationRules_1 = require("../utils/validationRules");
const defineTable_1 = require("../schema/defineTable");
const columnBuilder_1 = require("../schema/columnBuilder");
// Built-in admin table — tracks schema versions per actor sheet
const SCHEMA_VERSIONS_SCHEMA = (0, defineTable_1.defineTable)({
name: 'schema_versions',
actor: 'admin',
columns: {
schema_version_id: (0, columnBuilder_1.string)().primary(),
actor_sheet_id: (0, columnBuilder_1.string)().required(),
table_name: (0, columnBuilder_1.string)().required(),
schema_hash: (0, columnBuilder_1.string)().required(),
synced_at: (0, columnBuilder_1.string)().required(),
column_count: (0, columnBuilder_1.number)().required(),
},
});
class SheetAdapter {
constructor(config) {
this.schemas = new Map();
/** Cached Drive folder IDs: role -> folderId */
this._folderCache = new Map();
// Allow test injection via _client (cast through unknown for type safety)
this.cacheConfig = config.cache;
this.client = config._client
?? new sheetClient_1.SheetClient(config.credentials, config.tokens, this.cacheConfig);
this.credentials = config.credentials;
this.adminSheetId = config.adminSheetId;
this.onSchemaMismatch = config.onSchemaMismatch;
this.permissions = config.permissions;
this.driveFolder = config.driveFolder;
this.sharedDriveId = config.sharedDriveId;
this.tokenStore = config.tokenStore;
this.storage = config.storage;
this.sheetStyle = {
headerColor: config.sheetStyle?.headerColor ?? DEFAULT_HEADER_COLOR,
freezeHeader: config.sheetStyle?.freezeHeader ?? true,
freezeFirstColumn: config.sheetStyle?.freezeFirstColumn ?? false,
booleanFormat: config.sheetStyle?.booleanFormat ?? 'TRUE_FALSE',
};
// Inject the admin SheetClient into DriveStorageAdapter so callers don't repeat credentials
const storageAsAny = this.storage;
if (storageAsAny && typeof storageAsAny['_setClient'] === 'function') {
storageAsAny['_setClient'](this.client);
}
}
registerSchema(schema) {
this.schemas.set(schema.name, schema);
this.detectCircularRefs();
}
registerSchemas(schemas) {
schemas.forEach((schema) => this.registerSchema(schema));
}
withContext(context) {
// Normalise actor/role: prefer `actor`, fall back to deprecated `role` with a warning.
let actorValue;
if (context.actor) {
actorValue = context.actor;
}
else if (context.role) {
console.warn('[lsdb] UserContext.role is deprecated — use actor instead. ' +
'See: https://github.com/longcelot/sheet-db#actors-vs-application-roles');
actorValue = context.role;
}
else {
throw new Error('[lsdb] withContext() requires either actor or role in UserContext');
}
// Normalise targetActor/targetRole: prefer `targetActor`, fall back to deprecated `targetRole`.
let targetActorValue;
if (context.targetActor) {
targetActorValue = context.targetActor;
}
else if (context.targetRole) {
console.warn('[lsdb] UserContext.targetRole is deprecated — use targetActor instead. ' +
'See: https://github.com/longcelot/sheet-db#actors-vs-application-roles');
targetActorValue = context.targetRole;
}
const normalised = {
userId: context.userId,
actor: actorValue,
role: actorValue,
actorSheetId: context.actorSheetId,
targetActor: targetActorValue,
targetSheetId: context.targetSheetId,
};
const newAdapter = Object.create(this);
newAdapter.context = normalised;
newAdapter._pendingSchemaCheck = undefined;
if (this.onSchemaMismatch && normalised.actorSheetId && normalised.role !== 'admin') {
newAdapter._pendingSchemaCheck = newAdapter._doSchemaVersionCheck(normalised);
}
return newAdapter;
}
asActor(targetActor, targetSheetId) {
if (!this.context) {
throw new PermissionError_1.PermissionError('Context required before calling asActor()', undefined);
}
// Pass actor:/targetActor: (not role:/targetRole:) so withContext does not emit deprecation warnings.
return this.withContext({
userId: this.context.userId,
actor: this.context.actor,
actorSheetId: this.context.actorSheetId,
targetActor,
targetSheetId,
});
}
table(tableName) {
const schema = this.schemas.get(tableName);
if (!schema) {
throw new SchemaError_1.SchemaError(`Table ${tableName} is not registered`, tableName);
}
const spreadsheetId = this.resolveSpreadsheetId(schema);
if (!this.hasPermission(schema)) {
throw new PermissionError_1.PermissionError(`User does not have permission to access ${tableName}`, this.context?.role);
}
const fkResolver = this.createFKResolver();
return new crud_1.CRUDOperations(this.client, spreadsheetId, schema, fkResolver, this._pendingSchemaCheck, this.sheetStyle.booleanFormat);
}
async createUserSheet(userId, role, email, options) {
// Resolve actor tokens: explicit > tokenStore > none (fall back to admin client)
let actorTokens = options?.actorTokens;
if (!actorTokens && this.tokenStore) {
actorTokens = (await this.tokenStore.get(userId)) ?? undefined;
}
// Choose which client to use for spreadsheet creation
const clientForCreate = actorTokens
? new sheetClient_1.SheetClient(this.credentials, actorTokens, this.cacheConfig)
: this.client;
// Resolve Drive folder for this role (respects driveFolder config + sharedDriveId)
const folderId = await this.resolveFolderForRole(role, clientForCreate);
// Create the spreadsheet
const sheetId = await clientForCreate.createSpreadsheet(`${role}-${userId}`, {
folderId,
sharedDriveId: this.sharedDriveId,
});
if (actorTokens) {
// Sheet lives in actor's Drive — share with admin so admin can manage it
if (process.env.SUPER_ADMIN_EMAIL) {
await clientForCreate.shareWithUser(sheetId, process.env.SUPER_ADMIN_EMAIL, 'writer');
}
}
else {
// Sheet lives in admin's Drive — share with the actor and the admin email
if (process.env.SUPER_ADMIN_EMAIL) {
await this.client.shareWithUser(sheetId, process.env.SUPER_ADMIN_EMAIL, 'writer');
}
await this.client.shareWithUser(sheetId, email, 'writer');
}
const userTables = Array.from(this.schemas.values()).filter((s) => s.actor === role);
for (const table of userTables) {
await this.client.addSheet(sheetId, table.name);
const headers = Object.keys(table.columns);
await this.client.writeHeader(sheetId, table.name, headers);
await this._applySheetFormatting(sheetId, table, headers);
}
const adminTable = this.table('users');
await adminTable.create({
user_id: userId,
role,
email,
actor_sheet_id: sheetId,
created_at: new Date().toISOString(),
...options?.extraFields,
});
return sheetId;
}
async upload(file, options) {
if (!this.storage) {
throw new SchemaError_1.SchemaError('No storage adapter configured. Pass storage: new DriveStorageAdapter() to createSheetAdapter().');
}
return this.storage.upload(file, options);
}
async deleteFile(url) {
if (!this.storage) {
throw new SchemaError_1.SchemaError('No storage adapter configured. Pass storage: new DriveStorageAdapter() to createSheetAdapter().');
}
return this.storage.delete(url);
}
async syncSchema(schema) {
const spreadsheetId = this.resolveSpreadsheetId(schema);
const sheetExists = await this.sheetExists(schema);
if (!sheetExists) {
await this.client.addSheet(spreadsheetId, schema.name);
}
const schemaHeaders = Object.keys(schema.columns);
const rows = await this.client.getAllRows(spreadsheetId, schema.name);
if (rows.length === 0) {
await this.client.writeHeader(spreadsheetId, schema.name, schemaHeaders);
await this._applySheetFormatting(spreadsheetId, schema, schemaHeaders);
}
else {
const existingHeaders = rows[0];
const missingHeaders = schemaHeaders.filter((h) => !existingHeaders.includes(h));
if (missingHeaders.length > 0) {
const finalHeaders = [...existingHeaders, ...missingHeaders];
await this.client.writeHeader(spreadsheetId, schema.name, finalHeaders);
await this._applySheetFormatting(spreadsheetId, schema, finalHeaders, rows.length - 1);
}
}
}
// ── Schema version tracking ───────────────────────────────────────────────
async upsertSchemaVersion(actorSheetId, tableName, schemaHash, columnCount) {
try {
await this._ensureSchemaVersionsSheet();
const crud = this._svCrud();
const existing = await crud.findOne({
where: { actor_sheet_id: actorSheetId, table_name: tableName },
});
const synced_at = new Date().toISOString();
if (existing) {
await crud.update({
where: { actor_sheet_id: actorSheetId, table_name: tableName },
data: { schema_hash: schemaHash, synced_at, column_count: columnCount },
skipFKValidation: true,
});
}
else {
await crud.create({ actor_sheet_id: actorSheetId, table_name: tableName, schema_hash: schemaHash, synced_at, column_count: columnCount }, { skipFKValidation: true });
}
}
catch {
// Silently ignore version tracking errors to not break CRUD operations
}
}
async getSchemaVersion(actorSheetId, tableName) {
try {
await this._ensureSchemaVersionsSheet();
const crud = this._svCrud();
const record = await crud.findOne({ where: { actor_sheet_id: actorSheetId, table_name: tableName } });
if (!record)
return null;
return {
schema_hash: record.schema_hash,
synced_at: record.synced_at,
column_count: record.column_count,
};
}
catch {
return null;
}
}
// ── Internal helpers ──────────────────────────────────────────────────────
async _applySheetFormatting(spreadsheetId, schema, headers, dataRowCount = 0) {
await this.client.formatSheet(spreadsheetId, schema.name, {
columnCount: headers.length,
headerColor: this.sheetStyle.headerColor,
freezeHeader: this.sheetStyle.freezeHeader,
freezeFirstColumn: this.sheetStyle.freezeFirstColumn,
validations: (0, validationRules_1.buildValidationRules)(headers, schema.columns, this.sheetStyle.booleanFormat),
dataRowCount,
});
}
_svCrud() {
return new crud_1.CRUDOperations(this.client, this.adminSheetId, SCHEMA_VERSIONS_SCHEMA);
}
async _ensureSchemaVersionsSheet() {
const sheets = await this.client.getSheetNames(this.adminSheetId);
if (!sheets.includes('schema_versions')) {
await this.client.addSheet(this.adminSheetId, 'schema_versions');
const headers = Object.keys(SCHEMA_VERSIONS_SCHEMA.columns);
await this.client.writeHeader(this.adminSheetId, 'schema_versions', headers);
await this._applySheetFormatting(this.adminSheetId, SCHEMA_VERSIONS_SCHEMA, headers);
}
}
async _doSchemaVersionCheck(context) {
if (!context.actorSheetId)
return;
const roleSchemas = Array.from(this.schemas.values()).filter((s) => s.actor === context.role && s.name !== 'schema_versions');
for (const schema of roleSchemas) {
const currentHash = (0, schemaHash_1.computeSchemaHash)(schema);
const stored = await this.getSchemaVersion(context.actorSheetId, schema.name);
if (!stored || stored.schema_hash !== currentHash) {
switch (this.onSchemaMismatch) {
case 'warn':
console.warn(`[lsdb] Schema mismatch: table '${schema.name}' on sheet '${context.actorSheetId}' ` +
`is outdated. Run 'lsdb sync --all-users' to fix.`);
break;
case 'error':
throw new SchemaMismatchError_1.SchemaMismatchError(schema.name, context.actorSheetId);
case 'auto-sync':
await this.syncSchema(schema);
await this.upsertSchemaVersion(context.actorSheetId, schema.name, currentHash, Object.keys(schema.columns).length);
break;
}
}
}
}
async resolveFolderForRole(role, client) {
if (!this.driveFolder)
return undefined;
if (this._folderCache.has(role))
return this._folderCache.get(role);
const rootId = await client.findOrCreateFolder(this.driveFolder.root, undefined, this.sharedDriveId);
const subfolderName = this.driveFolder.subfolders?.[role] ?? role;
const folderId = await client.findOrCreateFolder(subfolderName, rootId, this.sharedDriveId);
this._folderCache.set(role, folderId);
return folderId;
}
createFKResolver() {
return async (tableName, columnName, value) => {
const refSchema = this.schemas.get(tableName);
if (!refSchema) {
throw new SchemaError_1.SchemaError(`Referenced table '${tableName}' is not registered`, tableName);
}
const refSpreadsheetId = this.resolveSpreadsheetId(refSchema);
const refCrud = new crud_1.CRUDOperations(this.client, refSpreadsheetId, refSchema);
const row = await refCrud.findOne({ where: { [columnName]: value } });
return row !== null;
};
}
detectCircularRefs() {
const adj = new Map();
for (const [name, schema] of this.schemas) {
const deps = new Set();
for (const col of Object.values(schema.columns)) {
if (col.ref) {
const [refTable] = col.ref.split('.');
deps.add(refTable);
}
}
adj.set(name, deps);
}
const visited = new Set();
const inStack = new Set();
const dfs = (node) => {
visited.add(node);
inStack.add(node);
for (const neighbor of adj.get(node) ?? new Set()) {
if (!adj.has(neighbor))
continue;
if (!visited.has(neighbor)) {
if (dfs(neighbor))
return true;
}
else if (inStack.has(neighbor)) {
return true;
}
}
inStack.delete(node);
return false;
};
for (const name of adj.keys()) {
if (!visited.has(name)) {
if (dfs(name)) {
throw new SchemaError_1.SchemaError('Circular reference detected in schema definitions');
}
}
}
}
resolveSpreadsheetId(schema) {
if (schema.actor === 'admin') {
return this.adminSheetId;
}
return (0, accessControl_1.resolveNonAdminTenantKey)(schema, this.context);
}
hasPermission(schema) {
return (0, accessControl_1.hasPermission)(schema, this.context, this.permissions);
}
async sheetExists(schema) {
const spreadsheetId = this.resolveSpreadsheetId(schema);
const sheetNames = await this.client.getSheetNames(spreadsheetId);
return sheetNames.includes(schema.name);
}
getClient() {
return this.client;
}
}
exports.SheetAdapter = SheetAdapter;
function createSheetAdapter(config) {
return new SheetAdapter(config);
}
//# sourceMappingURL=sheetAdapter.js.map