UNPKG

longcelot-sheet-db

Version:

Google Sheets-backed staging database adapter for Node.js with schema-first design

383 lines 17.7 kB
"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