diff --git a/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts b/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts new file mode 100644 index 00000000000..e03cdeedbd2 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { isUniqueViolationError } from '@objectstack/types'; +import { SqlDriver, classifyIndexKeyPart, parseIndexDdl } from '../src/index.js'; + +/** + * #23603 row 7 — the OLD hand-written table composite + * `indexes: [{ fields: ['organization_id', X], unique }]` constrains NOTHING on + * untenanted rows, because SQL UNIQUE is NULL-distinct; field-level + * `unique: true` does, because its organization key part is NULL-safe + * (ADR-0120 D3: `COALESCE(organization_id, '__global__')`). + * + * Replaces the driver half of objectstack-ai/hotcrm + * `test/case-number-tenant-scope.test.ts` (the R residue in #15953's + * classification table, row 7: its "the OLD table-composite spelling + * constrained nothing untenanted" reverse verification), so hotcrm can retire + * it (objectstack-ai/hotcrm#1583). The derivable half — every field declared + * unique collides within one organization and never across — is #15953's + * unique-scope family in `@objectstack/verify`; hotcrm's K residue (that + * `crm_case.case_number` is unique at all) stays in hotcrm. + * + * Why the hole matters: `organization_id` is kernel-injected and NULL on every + * row of a single-organization or untenanted install, and an autonumber + * sequence is keyed per tenant — so under the old spelling duplicate numbers + * were prevented only by the accident of a single sequence, never by the + * database. The old index still refuses a duplicate WITHIN one organization, + * which is exactly what hid the hole; both halves are pinned, against the + * field-level spelling on the same driver. + * + * A declared index's `unique: true` is the positional spelling of `'global'` + * (verbatim columns; ADR-0120 D2 converts stored rows to `'global'` and the + * spec refuses bare `true` since protocol 18). Both spellings are measured, + * because stored metadata older than the conversion reaches the driver bare. + */ +describe('row 7 — the old table composite is NULL-distinct, field-level unique is NULL-safe (hotcrm case-number-tenant-scope)', () => { + let driver: SqlDriver; + + beforeEach(() => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + }); + afterEach(async () => { + await driver.disconnect(); + }); + + /** Unique index name → key parts; the NULL-safe part reads `COALESCE(col)`. */ + async function uniqueKeyParts(table: string): Promise> { + const k = (driver as any).knex; + const master: any = await k.raw(`SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`, [table]); + const out: Record = {}; + for (const r of master as Array<{ name: string; sql: string | null }>) { + if (!r.sql || !/^CREATE UNIQUE/i.test(r.sql)) continue; + const parsed = parseIndexDdl(r.sql); + out[r.name] = (parsed?.keyParts ?? []).map((p) => { + const part = classifyIndexKeyPart(p); + if (part.kind === 'column') return part.column; + return part.column === null ? p : `COALESCE(${part.column})`; + }); + } + return out; + } + + const refusal = (p: Promise) => p.then(() => undefined, (e: unknown) => e); + + describe('field-level unique: true (the fix)', () => { + beforeEach(async () => { + await driver.initObjects([ + { name: 'kase', fields: { organization_id: { type: 'string' }, case_number: { type: 'string', unique: true } } }, + ] as any); + }); + + it('materializes the per-organization unique with a NULL-safe organization key part', async () => { + expect(await uniqueKeyParts('kase')).toEqual({ + uniq_kase_organization_id_case_number: ['COALESCE(organization_id)', 'case_number'], + }); + }); + + it('accepts the same number in two organizations and refuses it twice in one', async () => { + await driver.create('kase', { organization_id: 'org_a', case_number: 'CASE-1' }); + await driver.create('kase', { organization_id: 'org_b', case_number: 'CASE-1' }); + const err = await refusal(driver.create('kase', { organization_id: 'org_a', case_number: 'CASE-1' })); + expect(isUniqueViolationError(err)).toBe(true); + }); + + it('refuses a duplicate on an UNTENANTED install too', async () => { + await driver.create('kase', { case_number: 'CASE-2' }); + const err = await refusal(driver.create('kase', { case_number: 'CASE-2' })); + expect(isUniqueViolationError(err)).toBe(true); + }); + }); + + describe.each([ + ['bare unique: true', true], + ["unique: 'global'", 'global'], + ] as const)('the old table composite, %s (the reverse verification)', (_label, unique) => { + beforeEach(async () => { + await driver.initObjects([ + { + name: 'kase', + fields: { organization_id: { type: 'string' }, case_number: { type: 'string' } }, + indexes: [{ fields: ['organization_id', 'case_number'], unique }], + }, + ] as any); + }); + + it('materializes a plain composite with no NULL-safe key part', async () => { + expect(await uniqueKeyParts('kase')).toEqual({ + uniq_kase_organization_id_case_number: ['organization_id', 'case_number'], + }); + }); + + it('still refuses a duplicate within one organization — which is what hid the hole', async () => { + await driver.create('kase', { organization_id: 'org_a', case_number: 'CASE-10' }); + const err = await refusal(driver.create('kase', { organization_id: 'org_a', case_number: 'CASE-10' })); + expect(isUniqueViolationError(err)).toBe(true); + }); + + it('ACCEPTS a duplicate untenanted number — SQL UNIQUE is NULL-distinct', async () => { + const first = await driver.create('kase', { case_number: 'CASE-11' }); + const second = await driver.create('kase', { case_number: 'CASE-11' }); + expect(second.id).not.toBe(first.id); + expect([first.organization_id ?? null, second.organization_id ?? null]).toEqual([null, null]); + }); + }); +}); diff --git a/packages/objectql/src/engine-filter-token-today-spelling.test.ts b/packages/objectql/src/engine-filter-token-today-spelling.test.ts new file mode 100644 index 00000000000..c78aa5ebd48 --- /dev/null +++ b/packages/objectql/src/engine-filter-token-today-spelling.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23603 row 11 — the query layer's filter-token vocabulary: `resolveFilterTokens` + * REFUSES the `{TODAY()}` spelling and KNOWS `{today}`. + * + * Replaces the two query-layer PREMISE cases of objectstack-ai/hotcrm + * `test/flow-filter-today-token.test.ts` (row 11 of #15953's classification + * table), so hotcrm can retire them (objectstack-ai/hotcrm#1583). The + * service-automation half — the flow pre-pass that resolves its own date + * spelling before the query — is its own card and is not pinned here, and the + * static census of shipped flow filters is #15953's D residue. + * + * hotcrm pinned this because its flow sweeps rest on the two layers + * DISAGREEING about the token: the query layer must refuse `{TODAY()}` loudly + * (`FILTER_TOKEN_UNKNOWN` / 400) rather than compare it as a literal string — + * which matches nothing and reads exactly like an empty answer — and must + * resolve `{today}` to the calendar day, so `$lt '{today}'` excludes a row + * dated today. Both are pinned at the resolver and on the engine's read path + * (the refusal before the driver is reached, the resolution on the rows it + * selects). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resolveFilterTokens } from '@objectstack/core'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +const ymd = (offsetDays: number) => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + offsetDays); + return d.toISOString().slice(0, 10); +}; +const UTC = { timezone: 'UTC' }; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { let st = stores.get(o); if (!st) { st = new Map(); stores.set(o, st); } return st; }; + const matchOne = (stored: any, spec: any): boolean => { + if (spec !== null && typeof spec === 'object') { + return Object.entries(spec).every(([op, cmp]: [string, any]) => { + if (op === '$lt') return typeof stored === 'string' && typeof cmp === 'string' && stored < cmp; + if (op === '$eq') return stored === cmp; + throw new Error(`fake driver: unsupported operator ${op}`); + }); + } + return stored === spec; + }; + const matches = (row: any, where: any): boolean => + !where || typeof where !== 'object' || Object.entries(where).every(([k, v]) => matchOne(row?.[k], v)); + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const hit = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit; + }, + async findOne(o: string, ast: any) { return Array.from(storeFor(o).values()).find((r) => matches(r, ast?.where)) ?? null; }, + async create(o: string, data: any) { storeFor(o).set(data.id, data); return data; }, + async update(o: string, id: string, data: any) { const r = { ...storeFor(o).get(id), ...data, id }; storeFor(o).set(id, r); return r; }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string) { return storeFor(o).size; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +describe('row 11 — the query layer refuses {TODAY()} and knows {today} (hotcrm flow-filter-today-token)', () => { + describe('resolveFilterTokens', () => { + it('refuses {TODAY()} as FILTER_TOKEN_UNKNOWN / 400 instead of passing it through as a literal', () => { + let err: any; + try { resolveFilterTokens({ end_date: { $lt: '{TODAY()}' } }, UTC); } catch (e) { err = e; } + expect(err).toMatchObject({ code: 'FILTER_TOKEN_UNKNOWN', status: 400, token: 'TODAY()' }); + }); + + it('resolves {today} to the calendar day, YYYY-MM-DD', () => { + const now = new Date('2026-03-10T23:30:00.000Z'); + expect(resolveFilterTokens({ end_date: { $lt: '{today}' } }, { ...UTC, now })) + .toEqual({ end_date: { $lt: '2026-03-10' } }); + }); + }); + + describe('on the engine read path', () => { + let engine: ObjectQL; + let d: ReturnType; + let driverReads: number; + + beforeEach(async () => { + const quiet: any = { warn() {}, debug() {}, info() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } }; + engine = new ObjectQL({ logger: quiet }); + d = makeDriver(); + // Every read that reaches the driver, counted outside the double. + driverReads = 0; + for (const verb of ['find', 'findOne'] as const) { + const real = d.driver[verb]; + d.driver[verb] = (...args: unknown[]) => { driverReads += 1; return real.apply(d.driver, args); }; + } + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'contract', + fields: { status: { type: 'text' }, end_date: { type: 'date' } }, + } as any); + for (const [id, offset] of [['k_past', -3], ['k_yesterday', -1], ['k_today', 0], ['k_future', 30]] as const) { + d.storeFor('contract').set(id, { id, status: 'activated', end_date: ymd(offset) }); + } + }); + + it('a find carrying {TODAY()} is refused before the driver is reached', async () => { + const query: EngineQueryOptions = { + where: { status: 'activated', end_date: { $lt: '{TODAY()}' } }, context: UTC, + }; + const err = await engine.find('contract', query).then(() => undefined, (e: any) => e); + + expect(err).toMatchObject({ code: 'FILTER_TOKEN_UNKNOWN', status: 400 }); + expect(driverReads).toBe(0); + }); + + it('a find carrying {today} selects exactly the rows dated before today', async () => { + const query: EngineQueryOptions = { + where: { status: 'activated', end_date: { $lt: '{today}' } }, context: UTC, + }; + const rows = await engine.find('contract', query); + + expect(rows.map((r: any) => r.id).sort()).toEqual(['k_past', 'k_yesterday']); + }); + }); +}); diff --git a/packages/objectql/src/engine-readonly-update-strip-system-gate.test.ts b/packages/objectql/src/engine-readonly-update-strip-system-gate.test.ts new file mode 100644 index 00000000000..395b2a96a77 --- /dev/null +++ b/packages/objectql/src/engine-readonly-update-strip-system-gate.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23603 row 8 — on the UPDATE path a statically `readonly` field the CALLER + * supplies is stripped if and only if the context is not system; a value a + * `beforeUpdate` hook stamps survives under every context. + * + * Replaces the engine half of objectstack-ai/hotcrm + * `test/readonly-write-semantics.test.ts` (row 8 of #15953's classification + * table: its "plain caller PATCH" and "beforeUpdate hook stamp" describes, each + * measured under three caller contexts), so hotcrm can retire them + * (objectstack-ai/hotcrm#1583). The flow-writer describes (`runAs` decides + * `isSystem`) are service-automation's and are not pinned here; hotcrm's K + * residue (how its escalate path splits privilege) stays in hotcrm. + * + * ## The variable is `isSystem`, nothing else + * + * The strip's gate is `!opCtx.context?.isSystem` on the update path, and it + * takes only a key the caller supplied. hotcrm measured every writer against + * all three contexts so the reading names the variable instead of confounding + * it with the writer, and so does this file: `{ userId, isSystem }`, + * `{ isSystem }`, and `{ userId }` without `isSystem`. A `userId` does not buy + * the write; `isSystem` does, with or without one. + * + * Each case carries a CONTROL column, identical but for the flag: a run in + * which the control also fails to land measured a broken harness, not a strip. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + const matches = (row: any, where: any): boolean => + !where || typeof where !== 'object' || Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return row?.[k] === v; + }); + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; }, + async create(o: string, data: Record) { + n += 1; const id = (data.id as string) ?? `r_${n}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const row = { ...s.get(id), ...data, id }; s.set(id, row); return row; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count() { return 0; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const PROBE = 'probe_readonly'; + +/** The three caller contexts hotcrm varies, by name. */ +const CONTEXTS: Array<[label: string, ctx: Record]> = [ + ['{ userId, isSystem }', { userId: 'user_1', isSystem: true }], + ['{ isSystem }', { isSystem: true }], + ['{ userId } (no isSystem)', { userId: 'user_1' }], +]; + +describe('row 8 — the update-path readonly strip runs iff the context is not system (hotcrm readonly-write-semantics)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + + beforeEach(async () => { + const quiet: any = { warn() {}, debug() {}, info() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } }; + engine = new ObjectQL({ logger: quiet }); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: PROBE, + fields: { + name: { type: 'text' }, + locked_flag: { type: 'boolean', readonly: true }, + open_flag: { type: 'boolean' }, + }, + } as any); + // The hook writer: stamps the locked column — a value the caller did + // not supply — keyed off a sentinel so it fires only for those cases. + engine.registerHook('beforeUpdate', async (ctx: any) => { + if (ctx.input?.data?.name !== 'stamp-me') return; + ctx.input.data.locked_flag = true; + ctx.input.data.open_flag = true; + }, { object: PROBE }); + }); + + const seedRow = () => { + const id = `p_${storeFor(PROBE).size + 1}`; + storeFor(PROBE).set(id, { id, name: 'probe', locked_flag: false, open_flag: false }); + return id; + }; + const row = (id: string) => storeFor(PROBE).get(id); + + it.each(CONTEXTS)('a caller-supplied readonly value under %s lands exactly when isSystem', async (_label, context) => { + const id = seedRow(); + + await engine.update(PROBE, { id, locked_flag: true, open_flag: true }, { context } as any); + + expect(row(id).open_flag, 'the CONTROL column did not land — harness fault, not a strip').toBe(true); + expect(row(id).locked_flag).toBe(context.isSystem === true); + }); + + it.each(CONTEXTS)('a beforeUpdate hook stamp under %s survives — it was not supplied by the caller', async (_label, context) => { + const id = seedRow(); + + await engine.update(PROBE, { id, name: 'stamp-me' }, { context } as any); + + expect(row(id).open_flag, 'the CONTROL column did not land — harness fault, not a strip').toBe(true); + expect(row(id).locked_flag).toBe(true); + }); +}); diff --git a/packages/objectql/src/engine-referential-clear-write-context.test.ts b/packages/objectql/src/engine-referential-clear-write-context.test.ts new file mode 100644 index 00000000000..0b28c1449a8 --- /dev/null +++ b/packages/objectql/src/engine-referential-clear-write-context.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23603 rows 4-5 — the reference-cleanup write a `set_null` delete issues: an + * UPDATE of the holder, through the holder's own hooks, under the CALLER's + * context plus the referential-clear marker. + * + * Replaces the platform half of two objectstack-ai/hotcrm files (the R residues + * in #15953's classification table, rows 4 and 5), so hotcrm can retire those + * describes (objectstack-ai/hotcrm#1583): + * + * - `test/lead-duplicate-link-cleanup.test.ts` — "the engine runs a + * `set_null` clear as an UPDATE that reaches `beforeUpdate` with the + * caller's identity, indistinguishable from a hand-clear"; + * - `test/freeze-guard-reference-cleanup.test.ts` — the cleanup write's + * context table: the caller context spread, plus the marker. + * + * Their K halves stay in hotcrm: which hotcrm hook yields to the cleanup, and + * how, is the app's ruling. + * + * ## What is pinned + * + * `cascadeDeleteRelations` builds the cleanup context as + * `{ ...callerContext, __referentialFieldClear: true, … }` and writes + * `{ id, LINK: null }` through the public `update()`. So, on the holder's + * `beforeUpdate`: + * + * - the payload is `{ id, LINK: null }` (plus the audit stamps), nothing more; + * - the identity is whatever the DELETE carried — hotcrm measured three + * callers (a REST-shaped `{ userId, isSystem }`, a bare `{ isSystem }`, + * and no context at all) and found the two disagreeing readings in its + * own history were rig artefacts of exactly this inheritance; + * - against a hand-clear of the SAME lookup under the SAME identity, the + * payload and the session are identical, and the DECLARED + * `ctx.referentialFieldClear` is the one member that differs; + * - the referenced record is still readable at that moment, so existence is + * no discriminator either; + * - a holder hook that refuses the cleanup refuses the whole delete (the + * #720 shape hotcrm's freeze guards produced). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { EngineCountOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { if (!(v as any[]).some((sub) => matches(row, sub))) return false; continue; } + if (k === '$and') { if (!(v as any[]).every((sub) => matches(row, sub))) return false; continue; } + if (k.startsWith('$')) continue; + const want = v !== null && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (want ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; }, + async create(o: string, data: Record) { + nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const contact = { + name: 'contact', label: 'Contact', + fields: { name: { name: 'name', type: 'text' as const } }, +}; +/** The holder: an optional lookup on the spec default, `set_null`. */ +const lead = { + name: 'lead', label: 'Lead', + fields: { + name: { name: 'name', type: 'text' as const }, + duplicate_of: { name: 'duplicate_of', type: 'lookup' as const, reference: 'contact', deleteBehavior: 'set_null' as const }, + }, +}; + +type Seen = { + data: Record; + sessionUserId: unknown; + sessionIsSystem: unknown; + marker: unknown; + referencedStillReadable: number; +}; + +/** The three caller contexts hotcrm's table varies, by name. */ +const CALLERS: Array<[label: string, ctx: Record | undefined]> = [ + ['{ userId, isSystem } (a REST DELETE)', { userId: 'u1', isSystem: true }], + ['{ isSystem }', { isSystem: true }], + ['no context at all', undefined], +]; + +describe('rows 4-5 — the set_null cleanup is an UPDATE under the caller\'s context (hotcrm lead-duplicate-link-cleanup, freeze-guard-reference-cleanup)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + let seen: Seen[]; + let refuse: boolean; + + beforeEach(async () => { + engine = new ObjectQL(); + const d = makeStubDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + for (const o of [contact, lead]) engine.registry.registerObject(o as any); + seen = []; + refuse = false; + engine.on('beforeUpdate', 'lead', async (ctx: any) => { + const ref = storeFor('lead').get(ctx.input?.id)?.duplicate_of; + const byRef: EngineCountOptions = { where: { id: ref } }; + seen.push({ + data: { ...(ctx.input?.data ?? {}) }, + sessionUserId: ctx.session?.userId, + sessionIsSystem: ctx.session?.isSystem, + marker: ctx.referentialFieldClear, + referencedStillReadable: ref ? await engine.count('contact', byRef) : 0, + }); + if (refuse) throw new Error('settled record: the link may not change'); + }); + }); + + const seed = async () => { + const c = await engine.insert('contact', { name: 'Survivor' }); + const l = await engine.insert('lead', { name: 'Dupe', duplicate_of: c.id }); + return { c, l }; + }; + const payloadKeys = (s: Seen) => Object.keys(s.data).filter((k) => !['updated_at', 'updated_by'].includes(k)).sort(); + + it.each(CALLERS)('the cleanup inherits the DELETE\'s context — %s', async (_label, ctx) => { + const { c, l } = await seed(); + + await engine.delete('contact', { where: { id: c.id }, ...(ctx ? { context: ctx } : {}) } as any); + + expect(seen).toHaveLength(1); + const [w] = seen; + expect(w.data.duplicate_of, 'this is the cleanup write').toBeNull(); + expect(payloadKeys(w), 'it writes the row address and the link, nothing else').toEqual(['duplicate_of', 'id']); + expect(w.marker, 'and it is marked on the declared hook face').toBe(true); + expect(w.sessionUserId).toBe(ctx?.userId); + expect(w.sessionIsSystem ?? undefined).toBe(ctx?.isSystem); + expect(w.referencedStillReadable, 'the referenced record still exists while its holders are cleared').toBe(1); + expect(storeFor('lead').get(l.id)?.duplicate_of).toBeNull(); + expect(storeFor('contact').has(c.id)).toBe(false); + }); + + it('against a hand-clear under the same identity, only ctx.referentialFieldClear differs', async () => { + const CALLER = { userId: 'u1', isSystem: true }; + const { c, l } = await seed(); + + await engine.update('lead', { id: l.id, duplicate_of: null }, { context: { ...CALLER } } as any); + await engine.update('lead', { id: l.id, duplicate_of: c.id }, { context: { ...CALLER } } as any); + await engine.delete('contact', { where: { id: c.id }, context: { ...CALLER } } as any); + + expect(seen).toHaveLength(3); + const [hand, , cascade] = seen; + expect(payloadKeys(cascade)).toEqual(payloadKeys(hand)); + expect(cascade.data.duplicate_of).toBe(hand.data.duplicate_of); + expect([cascade.sessionUserId, cascade.sessionIsSystem]).toEqual([hand.sessionUserId, hand.sessionIsSystem]); + expect(cascade.referencedStillReadable).toBe(1); + expect(hand.marker).toBeUndefined(); + expect(cascade.marker).toBe(true); + }); + + it('a holder hook that refuses the cleanup write refuses the whole delete', async () => { + const { c, l } = await seed(); + refuse = true; + + await expect(engine.delete('contact', { where: { id: c.id }, context: { userId: 'u1', isSystem: true } } as any)) + .rejects.toThrow('settled record'); + + expect(seen).toHaveLength(1); + expect(storeFor('contact').has(c.id), 'the referenced record survives').toBe(true); + expect(storeFor('lead').get(l.id)?.duplicate_of).toBe(c.id); + }); +}); diff --git a/packages/objectql/src/engine-referential-required-lookup-default-restrict.test.ts b/packages/objectql/src/engine-referential-required-lookup-default-restrict.test.ts new file mode 100644 index 00000000000..b435a70d099 --- /dev/null +++ b/packages/objectql/src/engine-referential-required-lookup-default-restrict.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23603 row 3 — the referential pass ESCALATES a spec-default `set_null` on a + * REQUIRED lookup to `restrict`. + * + * Replaces the platform half of objectstack-ai/hotcrm `test/line-item-cascade.test.ts` + * (the R residue in #15953's classification table, row 3), so hotcrm can retire + * that half (objectstack-ai/hotcrm#1583). hotcrm keeps its K half — which of + * its own lookups cascade — because that is a business ruling, not an engine + * fact. + * + * ## The semantics hotcrm pinned, and why they are the engine's + * + * A required lookup that declares no `deleteBehavior` is not "no opinion": + * `FieldSchema` materializes the contract default `set_null` on it, so the + * resolved field reads `set_null` with nothing in source. `cascadeDeleteRelations` + * then escalates that `set_null` to `restrict`, because a NOT NULL column cannot + * be cleared. A delete of the referenced record is refused as + * `DELETE_RESTRICTED` / 409 BEFORE any write reaches the dependent — so the + * caller is never answered with the dependent's own `required` 400 about a + * field on an object it did not address. + * + * Everything here goes through the spec seam the way an app's metadata does: + * the fields are `FieldSchema.parse`d, so the premise (the materialized + * default) and the consequence (the refusal) are read on the same field. + * The two controls keep the refusal attributable: the same lookup without + * `required` is cleared and the parent deleted, and an authored `cascade` on + * the same required lookup removes exactly the dependents of the deleted + * record and no other. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { FieldSchema } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + let nextId = 0; + const matchOne = (stored: unknown, spec: unknown): boolean => { + if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) { + const [op, cmp] = Object.entries(spec as Record)[0] ?? []; + if (op === '$eq') return (stored ?? null) === ((cmp as any) ?? null); + if (op === '$in') return (cmp as unknown[]).includes(stored); + return false; + } + return (stored ?? null) === ((spec as any) ?? null); + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { if (!(v as any[]).some((sub) => matches(row, sub))) return false; continue; } + if (k === '$and') { if (!(v as any[]).every((sub) => matches(row, sub))) return false; continue; } + if (k.startsWith('$')) continue; + if (!matchOne(row[k], v)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; }, + async create(o: string, data: Record) { + nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const text = (name: string) => ({ name, ...FieldSchema.parse({ name, type: 'text' }) }); +/** The parent lookup, as an app would author it: no `deleteBehavior` at all. */ +const parentLookup = (extra: Record = {}) => + ({ name: 'deal', ...FieldSchema.parse({ name: 'deal', label: 'Deal', type: 'lookup', reference: 'deal', ...extra }) }); + +const deal = { name: 'deal', label: 'Deal', fields: { name: text('name') } }; +const requiredLine = { + name: 'deal_line', label: 'Deal Line', + fields: { label: text('label'), deal: parentLookup({ required: true }) }, +}; +const optionalNote = { + name: 'deal_note', label: 'Deal Note', + fields: { body: text('body'), deal: parentLookup() }, +}; +const cascadingLine = { + name: 'deal_part', label: 'Deal Part', + fields: { label: text('label'), deal: parentLookup({ required: true, deleteBehavior: 'cascade' }) }, +}; + +describe('row 3 — a spec-default set_null on a REQUIRED lookup escalates to restrict (hotcrm line-item-cascade)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + let lineWrites: number; + + beforeEach(async () => { + engine = new ObjectQL(); + const d = makeStubDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + for (const o of [deal, requiredLine, optionalNote, cascadingLine]) engine.registry.registerObject(o as any); + lineWrites = 0; + engine.on('beforeUpdate', 'deal_line', () => { lineWrites += 1; }); + }); + + it('PREMISE: declaring nothing resolves to set_null on the parsed field', () => { + expect(requiredLine.fields.deal.required).toBe(true); + expect(requiredLine.fields.deal.deleteBehavior).toBe('set_null'); + }); + + it('refuses the parent delete as DELETE_RESTRICTED / 409, before any write reaches the dependent', async () => { + const d = await engine.insert('deal', { name: 'Itemised' }); + const line = await engine.insert('deal_line', { label: 'L1', deal: d.id }); + + const err = await engine.delete('deal', { where: { id: d.id } } as any).catch((e) => e); + // ADR-0112 envelope: an unescalated engine fails here with the + // dependent's `required` VALIDATION_FAILED instead. + expect(err).toMatchObject({ + code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'deal_line', dependentCount: 1, + }); + expect(lineWrites, 'the escalation precedes the set_null write').toBe(0); + expect(storeFor('deal').has(d.id)).toBe(true); + expect(storeFor('deal_line').get(line.id)?.deal).toBe(d.id); + }); + + it('control: the same lookup WITHOUT required is cleared and the parent deleted', async () => { + const d = await engine.insert('deal', { name: 'Noted' }); + const note = await engine.insert('deal_note', { body: 'n', deal: d.id }); + + await engine.delete('deal', { where: { id: d.id } } as any); + + expect(storeFor('deal').has(d.id)).toBe(false); + expect(storeFor('deal_note').get(note.id)?.deal).toBeNull(); + }); + + it('control: an authored cascade on the same required lookup removes exactly the deleted record\'s dependents', async () => { + const doomed = await engine.insert('deal', { name: 'Doomed' }); + const bystander = await engine.insert('deal', { name: 'Bystander' }); + await engine.insert('deal_part', { label: 'gone', deal: doomed.id }); + const keep = await engine.insert('deal_part', { label: 'kept', deal: bystander.id }); + + await engine.delete('deal', { where: { id: doomed.id } } as any); + + expect(storeFor('deal').has(doomed.id)).toBe(false); + expect([...storeFor('deal_part').keys()]).toEqual([keep.id]); + expect(storeFor('deal').has(bystander.id)).toBe(true); + }); +}); diff --git a/packages/objectql/src/engine-undeclared-key-caller-path.test.ts b/packages/objectql/src/engine-undeclared-key-caller-path.test.ts new file mode 100644 index 00000000000..645114327e5 --- /dev/null +++ b/packages/objectql/src/engine-undeclared-key-caller-path.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23603 row 10 — on the CALLER path a key the object does not declare is + * refused as `INVALID_FIELD` / 400, on insert and on update, before anything + * is written. + * + * Replaces the caller-path half of objectstack-ai/hotcrm + * `test/undeclared-key-probe.test.ts` (row 10 of #15953's classification + * table, its "half 1"), so hotcrm can retire it (objectstack-ai/hotcrm#1583). + * The per-driver half — a key a HOOK writes refused as one ADR-0112 envelope + * on every driver — is a driver conformance and stays on #15953; it is not + * pinned here. + * + * hotcrm's reason for pinning it: every hook, action and flow writes by NAME, + * and a misspelled key was once a silent successful write that stored a field + * the object never declared — outside field-level security by construction, + * since an undeclared field carries no permission entry. The guarantee is + * pinned WITH hotcrm's two controls, without which a red-free run could be + * measuring nothing: the same row without the key is accepted, and the same + * key on an object that DECLARES it is accepted and stored. A plain + * misspelling of a declared field is refused the same way, so the refusal is + * about declaration, not about one borrowed name. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +function makeRecordingDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + const writes: string[] = []; + const matches = (row: any, where: any): boolean => + !where || typeof where !== 'object' || Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return row?.[k] === v; + }); + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; }, + async create(o: string, data: Record) { + writes.push(`create ${o}`); + n += 1; const id = (data.id as string) ?? `r_${n}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + writes.push(`update ${o}`); + const s = storeFor(o); const row = { ...s.get(id), ...data, id }; s.set(id, row); return row; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count() { return 0; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, storeFor, writes }; +} + +/** Declared on `quote`, not on `quote_line` — hotcrm's declared/undeclared pair. */ +const KEY = 'tax'; +/** A plain misspelling of a field `quote_line` really declares. */ +const TYPO = 'unit_pric'; +const SYS = { isSystem: true }; + +const refusal = (p: Promise) => p.then(() => undefined, (e: any) => e); + +describe('row 10 — an undeclared key on the caller path is refused as INVALID_FIELD / 400 (hotcrm undeclared-key-probe)', () => { + let engine: ObjectQL; + let rec: ReturnType; + + beforeEach(async () => { + const quiet: any = { warn() {}, debug() {}, info() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } }; + engine = new ObjectQL({ logger: quiet }); + rec = makeRecordingDriver(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'quote_line', + fields: { quantity: { type: 'number' }, unit_price: { type: 'number' } }, + } as any); + engine.registry.registerObject({ + name: 'quote', + fields: { name: { type: 'text' }, [KEY]: { type: 'number' } }, + } as any); + }); + + it('control: the same row WITHOUT the key is accepted', async () => { + const row = await engine.insert('quote_line', { quantity: 1, unit_price: 100 }, { context: SYS } as any); + expect(rec.storeFor('quote_line').get(row.id)?.unit_price).toBe(100); + }); + + it('control: the same key on an object that DECLARES it is accepted and stored', async () => { + const row = await engine.insert('quote', { name: 'Q', [KEY]: 10 }, { context: SYS } as any); + expect(rec.storeFor('quote').get(row.id)?.[KEY]).toBe(10); + }); + + it('insert carrying the undeclared key is refused, and nothing reaches the driver', async () => { + const err = await refusal(engine.insert('quote_line', { quantity: 1, unit_price: 100, [KEY]: 10 }, { context: SYS } as any)); + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400 }); + expect(String(err?.message)).toContain(KEY); + expect(rec.writes).toEqual([]); + }); + + it('update carrying the undeclared key is refused the same way, and the row is unchanged', async () => { + rec.storeFor('quote_line').set('l1', { id: 'l1', quantity: 1, unit_price: 100 }); + + const err = await refusal(engine.update('quote_line', { id: 'l1', [KEY]: 42 }, { context: SYS } as any)); + + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400 }); + expect(String(err?.message)).toContain(KEY); + expect(rec.writes).toEqual([]); + expect(rec.storeFor('quote_line').get('l1')).toEqual({ id: 'l1', quantity: 1, unit_price: 100 }); + }); + + it('a plain misspelling of a declared field is refused too', async () => { + const err = await refusal(engine.insert('quote_line', { quantity: 1, [TYPO]: 7 }, { context: SYS } as any)); + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400 }); + expect(String(err?.message)).toContain(TYPO); + expect(rec.writes).toEqual([]); + }); +});