UNPKG

longcelot-sheet-db

Version:

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

439 lines 21.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PrismaAdapterBase = void 0; exports.createPrismaAdapter = createPrismaAdapter; const nanoid_1 = require("nanoid"); const ValidationError_1 = require("../../errors/ValidationError"); const PermissionError_1 = require("../../errors/PermissionError"); const SchemaError_1 = require("../../errors/SchemaError"); const accessControl_1 = require("../accessControl"); const prismaNaming_1 = require("../../utils/prismaNaming"); const SOFT_DELETE_COLUMN = '_deleted_at'; function getModelDelegate(client, schema) { const modelName = (0, prismaNaming_1.toPrismaModelName)(schema.name); const record = client; const delegate = record[modelName]; if (!delegate || typeof delegate !== 'object') { throw new SchemaError_1.SchemaError(`Prisma client has no model '${modelName}' for table '${schema.name}'. Run 'lsdb migrate --prisma' ` + `and 'prisma generate' after adding or renaming this table.`, schema.name); } return delegate; } /** Prisma's own error codes for constraint violations — https://www.prisma.io/docs/orm/reference/error-reference */ function translatePrismaError(err) { const code = typeof err === 'object' && err !== null && 'code' in err ? String(err.code) : undefined; const detail = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : 'constraint violation'; if (code === 'P2002') return new ValidationError_1.ValidationError(`Unique constraint violation: ${detail}`); if (code === 'P2003') return new ValidationError_1.ValidationError(`FK violation: ${detail}`); return null; } /** * Tenant-scoped equivalent of createSQLFKResolver() (fkResolver.ts) and * SheetAdapter.createFKResolver() — resolves the referenced table's *actual* actor from the * schema registry (not the calling table's) to decide whether the check needs tenant scoping at * all, and reuses resolveNonAdminTenantKey() so a same-actor FK stays inside one tenant's data * instead of checking globally. See FAQ.md #13 — getting this wrong is a cross-tenant leak. */ function createPrismaFKResolver(client, schemas, tenantColumn, context) { return async (tableName, columnName, value) => { const refSchema = schemas.get(tableName); if (!refSchema) { throw new SchemaError_1.SchemaError(`Referenced table '${tableName}' is not registered`, tableName); } // `columnName` is a raw column name (e.g. could be `_id`) — translate to the referenced // model's actual Prisma field name (see toPrismaFieldName()). const refFieldName = (0, prismaNaming_1.buildPrismaFieldMap)(refSchema).toField[columnName] ?? columnName; const where = { [refFieldName]: value }; if (refSchema.actor !== 'admin') { where[tenantColumn] = (0, accessControl_1.resolveNonAdminTenantKey)(refSchema, context); } const row = await getModelDelegate(client, refSchema).findFirst({ where }); return row !== null; }; } /** * TableOperations backed by a consumer-provided PrismaClient. Same validation/defaults/FK/ * soft-delete/uniqueness/timestamps semantics as CRUDOperations and SQLTableOperations — see * TODO.md Phase 16.2. Deliberately its own copy of the validation logic rather than a shared * module yet, matching SQLTableOperations' documented first-pass tradeoff. */ class PrismaTableOperations { constructor(client, schema, tenantColumn, tenantValue, fkResolver) { this.client = client; this.schema = schema; this.tenantColumn = tenantColumn; this.tenantValue = tenantValue; this.fkResolver = fkResolver; this.fieldMap = (0, prismaNaming_1.buildPrismaFieldMap)(schema); } get delegate() { return getModelDelegate(this.client, this.schema); } /** * Drops any key that isn't a declared column on this schema before it reaches * toFieldKeys()/the Prisma delegate — a stray key (e.g. a legacy/leftover Sheets column * migrate-data read verbatim off a real spreadsheet row) would otherwise hit Prisma's * "Unknown argument" error, the Prisma-side equivalent of the raw SQL adapters' native "column * ... does not exist" (found via a real F2 cutover run — see FAQ.md §13, and the matching fix * in SQLTableOperations.serializeRow()). Every column this table actually has, including the * system ones, is a real entry in this.schema.columns. Only ever applied to a data payload * (create/createMany/update), never to a where clause — the tenant/soft-delete keys buildWhere() * adds aren't schema columns and must pass through untouched there. */ filterKnownColumns(data) { const result = {}; for (const [key, value] of Object.entries(data)) { if (key in this.schema.columns) result[key] = value; } return result; } /** Raw column names (e.g. `_id`) -> Prisma Client field names (e.g. `id`) — see toPrismaFieldName(). */ toFieldKeys(obj) { const result = {}; for (const [key, value] of Object.entries(obj)) { result[this.fieldMap.toField[key] ?? key] = value; } return result; } /** Prisma Client field names -> raw column names — the inverse of toFieldKeys(). */ toRawKeys(obj) { const result = {}; for (const [key, value] of Object.entries(obj)) { result[this.fieldMap.toRaw[key] ?? key] = value; } return result; } async create(data, options = {}) { let incoming = { ...data }; if (this.schema.pkColumn) { const pkDef = this.schema.columns[this.schema.pkColumn]; if (pkDef?.type === 'string' && (incoming[this.schema.pkColumn] === undefined || incoming[this.schema.pkColumn] === null)) { incoming[this.schema.pkColumn] = (0, nanoid_1.nanoid)(); } } const dataWithId = { _id: (0, nanoid_1.nanoid)(), ...incoming }; const validated = this.validateAndApplyDefaults(dataWithId, 'create'); if (!options.skipFKValidation) { await this.validateForeignKeys(validated); } await this.checkUniqueness(validated, null); if (this.schema.timestamps) { const now = new Date().toISOString(); // Preserve a caller-supplied timestamp when present (e.g. lsdb migrate-data upserting the // exact _created_at/_updated_at it read from the Sheets source, to keep migrated rows' // original history intact) — only default to "now" for the normal create() path, which // never supplies these itself. if (validated._created_at === undefined || validated._created_at === null) validated._created_at = now; if (validated._updated_at === undefined || validated._updated_at === null) validated._updated_at = now; } const row = { ...validated }; if (this.tenantValue !== undefined) row[this.tenantColumn] = this.tenantValue; try { await this.delegate.create({ data: this.toFieldKeys(this.filterKnownColumns(row)) }); } catch (err) { throw translatePrismaError(err) ?? err; } return validated; } async createMany(records, options = {}) { if (records.length === 0) return []; const results = []; const rows = []; for (const data of records) { let incoming = { ...data }; if (this.schema.pkColumn) { const pkDef = this.schema.columns[this.schema.pkColumn]; if (pkDef?.type === 'string' && (incoming[this.schema.pkColumn] === undefined || incoming[this.schema.pkColumn] === null)) { incoming[this.schema.pkColumn] = (0, nanoid_1.nanoid)(); } } const dataWithId = { _id: (0, nanoid_1.nanoid)(), ...incoming }; const validated = this.validateAndApplyDefaults(dataWithId, 'create'); if (!options.skipFKValidation) { await this.validateForeignKeys(validated); } await this.checkUniqueness(validated, null); if (this.schema.timestamps) { const now = new Date().toISOString(); // See create()'s matching comment — preserve a caller-supplied timestamp when present. if (validated._created_at === undefined || validated._created_at === null) validated._created_at = now; if (validated._updated_at === undefined || validated._updated_at === null) validated._updated_at = now; } results.push(validated); const row = { ...validated }; if (this.tenantValue !== undefined) row[this.tenantColumn] = this.tenantValue; rows.push(row); } // Prisma's createMany() doesn't return created rows on most databases — every value was // already computed app-side above (matches CRUDOperations/SQLTableOperations), so `results` // is returned directly rather than relying on Prisma to echo them back. try { await this.delegate.createMany({ data: rows.map((row) => this.toFieldKeys(this.filterKnownColumns(row))) }); } catch (err) { throw translatePrismaError(err) ?? err; } return results; } async findMany(options = {}) { const where = this.toFieldKeys(this.buildWhere(options.where, options.includeDeleted)); const args = { where }; if (options.orderBy) { const orderField = this.fieldMap.toField[options.orderBy] ?? options.orderBy; args.orderBy = { [orderField]: options.order === 'desc' ? 'desc' : 'asc' }; } if (options.offset) args.skip = options.offset; if (options.limit) args.take = options.limit; const rows = await this.delegate.findMany(args); return rows.map((row) => this.stripTenantColumn(this.toRawKeys(row))); } async findOne(options = {}) { const results = await this.findMany({ ...options, limit: 1 }); return results[0] || null; } async update(options) { const updateData = { ...options.data }; if (this.schema.pkColumn && this.schema.pkColumn in updateData) { delete updateData[this.schema.pkColumn]; } const validated = this.validateAndApplyDefaults(updateData, 'update'); if (!options.skipFKValidation) { await this.validateForeignKeys(validated); } const where = this.toFieldKeys(this.buildWhere(options.where, true)); const matched = await this.delegate.findMany({ where }); if (matched.length === 0) return 0; for (const row of matched) { await this.checkUniqueness(validated, String(this.toRawKeys(row)._id)); } if (this.schema.timestamps) { validated._updated_at = new Date().toISOString(); } try { const result = await this.delegate.updateMany({ where, data: this.toFieldKeys(this.filterKnownColumns(validated)) }); return result.count; } catch (err) { throw translatePrismaError(err) ?? err; } } async upsert(options) { // includeDeleted: see the matching fix in SQLTableOperations.upsert() (FAQ.md §13) — without // this, an already-soft-deleted target row is invisible to this existence check, so upsert() // wrongly takes the create() branch against a row that's still physically there. const existing = await this.findOne({ where: options.where, includeDeleted: true }); if (existing) { // See the matching fix in SQLTableOperations.upsert() (FAQ.md §13): a caller upserting from // a full row snapshot has no intention of rewriting readonly/system columns on an // already-existing row, so strip them before calling update() rather than relaxing its // readonly check. const updateData = { ...options.data }; for (const columnName of Object.keys(this.schema.columns)) { if (this.schema.columns[columnName].readonly) delete updateData[columnName]; } await this.update({ where: options.where, data: updateData, skipFKValidation: options.skipFKValidation }); return { ...existing, ...updateData }; } return await this.create({ ...options.where, ...options.data }, { skipFKValidation: options.skipFKValidation }); } async count(options = {}) { const where = this.toFieldKeys(this.buildWhere(options.where, options.includeDeleted)); return this.delegate.count({ where }); } async delete(options) { if (this.schema.softDelete) { return await this.update({ where: options.where, data: { _deleted_at: new Date().toISOString() }, skipFKValidation: true, }); } const where = this.toFieldKeys(this.buildWhere(options.where, true)); const result = await this.delegate.deleteMany({ where }); return result.count; } buildWhere(where, includeDeleted) { const clause = {}; if (this.tenantValue !== undefined) clause[this.tenantColumn] = this.tenantValue; if (this.schema.softDelete && !includeDeleted) clause[SOFT_DELETE_COLUMN] = null; if (where) Object.assign(clause, where); return clause; } stripTenantColumn(row) { if (this.tenantValue === undefined) return row; const rest = { ...row }; delete rest[this.tenantColumn]; return rest; } // ── Validation (ported from CRUDOperations — same messages, same semantics) ───────────── validateAndApplyDefaults(data, mode) { const result = { ...data }; for (const [columnName, column] of Object.entries(this.schema.columns)) { const value = result[columnName]; if (column.readonly && mode === 'update' && columnName in data) { throw new ValidationError_1.ValidationError(`Column ${columnName} is readonly`, columnName); } if (value === undefined || value === null) { if (column.default !== undefined && mode === 'create') { result[columnName] = column.default; } else if (column.required && mode === 'create') { throw new ValidationError_1.ValidationError(`Column ${columnName} is required`, columnName); } continue; } if (column.enum && !column.enum.includes(value)) { throw new ValidationError_1.ValidationError(`Column ${columnName} must be one of: ${column.enum.join(', ')}`, columnName); } if (column.min !== undefined) { if (typeof value === 'string' && value.length < column.min) { throw new ValidationError_1.ValidationError(`Column ${columnName} must be at least ${column.min} characters`, columnName); } if (typeof value === 'number' && value < column.min) { throw new ValidationError_1.ValidationError(`Column ${columnName} must be at least ${column.min}`, columnName); } } if (column.max !== undefined) { if (typeof value === 'string' && value.length > column.max) { throw new ValidationError_1.ValidationError(`Column ${columnName} must be at most ${column.max} characters`, columnName); } if (typeof value === 'number' && value > column.max) { throw new ValidationError_1.ValidationError(`Column ${columnName} must be at most ${column.max}`, columnName); } } if (column.pattern && typeof value === 'string' && !column.pattern.test(value)) { throw new ValidationError_1.ValidationError(`Column ${columnName} does not match required pattern`, columnName); } } return result; } async validateForeignKeys(data) { if (!this.fkResolver) return; for (const [columnName, column] of Object.entries(this.schema.columns)) { if (!column.ref) continue; const value = data[columnName]; if (value === undefined || value === null) continue; const [refTable, refColumn] = column.ref.split('.'); const exists = await this.fkResolver(refTable, refColumn, value); if (!exists) { throw new ValidationError_1.ValidationError(`FK violation: ${refTable}.${refColumn} '${value}' does not exist`, columnName); } } } async checkUniqueness(data, excludeId) { for (const [columnName, column] of Object.entries(this.schema.columns)) { if (!column.unique) continue; const value = data[columnName]; if (value === undefined || value === null) continue; const existing = await this.findOne({ where: { [columnName]: value } }); if (existing && existing._id !== excludeId) { throw new ValidationError_1.ValidationError(`Unique constraint violation: column '${columnName}' already has value '${value}'`, columnName); } } } } class PrismaAdapterBase { constructor(client, tenantColumn, permissions) { this.client = client; this.tenantColumn = tenantColumn; this.permissions = permissions; this.schemas = new Map(); } registerSchema(schema) { this.schemas.set(schema.name, schema); } registerSchemas(schemas) { schemas.forEach((schema) => this.registerSchema(schema)); } withContext(context) { 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'); } 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; return newAdapter; } asActor(targetActor, targetSheetId) { if (!this.context) { throw new PermissionError_1.PermissionError('Context required before calling asActor()', undefined); } 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); } if (!(0, accessControl_1.hasPermission)(schema, this.context, this.permissions)) { throw new PermissionError_1.PermissionError(`User does not have permission to access ${tableName}`, this.context?.role); } const tenantValue = schema.actor === 'admin' ? undefined : (0, accessControl_1.resolveNonAdminTenantKey)(schema, this.context); const fkResolver = createPrismaFKResolver(this.client, this.schemas, this.tenantColumn, this.context); return new PrismaTableOperations(this.client, schema, this.tenantColumn, tenantValue, fkResolver); } } exports.PrismaAdapterBase = PrismaAdapterBase; /** * Wraps a consumer-provided PrismaClient to implement DatabaseAdapter — see PrismaAdapterConfig * for why this package doesn't generate/`prisma generate` a client itself (Phase 16.2). */ function createPrismaAdapter(config) { return new PrismaAdapterBase(config.client, config.tenantColumn ?? 'tenant_id', config.permissions); } //# sourceMappingURL=prismaAdapter.js.map