longcelot-sheet-db
Version:
Google Sheets-backed staging database adapter for Node.js with schema-first design
439 lines • 21.3 kB
JavaScript
"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