diff --git a/.changeset/hook-ctx-title-accessor.md b/.changeset/hook-ctx-title-accessor.md new file mode 100644 index 0000000000..32b69a3d02 --- /dev/null +++ b/.changeset/hook-ctx-title-accessor.md @@ -0,0 +1,26 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": minor +"@objectstack/cli": minor +--- + +**Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + +A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + +What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + +```js +// this record — nameField, formula or stored column alike +await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); +// a related record, through the lookup column that holds its id +const account = await ctx.title('account_id'); +``` + +**Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + +**Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + +**It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + +Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. diff --git a/content/docs/automation/hook-bodies.mdx b/content/docs/automation/hook-bodies.mdx index 3e3f938de0..625260a6a6 100644 --- a/content/docs/automation/hook-bodies.mdx +++ b/content/docs/automation/hook-bodies.mdx @@ -99,10 +99,45 @@ The script sees only what the surrounding `ctx` object exposes: | `ctx.user` / `ctx.session` | Identity context. | none | | `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` | | `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` | +| `ctx.title()` | This record's title — the object's `nameField`, **including when it is a `formula`** (evaluated server-side against the record already in hand, no extra read). | none | +| `ctx.title('')` | The related record's title, through a lookup / master_detail / user / tree column. Costs one `findOne`. | `api.read` | | `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` | | `ctx.log.{info,warn,error}` | Structured logging. | `log` | | `ctx.connector(name).(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) | +### Naming a record — `ctx.title()` + +A body composing a message needs the record's **name**, and until this accessor +existed it could not get one: `ctx.input` / `ctx.previous` carry stored columns, +while a `nameField` is very often a `formula` computed on read. The result was +that every hook re-implemented the object's title inline — or, more often, +printed `record.id`, which is the one identifier always in scope and the one +string the UI never shows. + +```ts +// this record — resolves `nameField`, formula or stored column alike +await ctx.api.object('sys_notification').insert({ + subject: `${await ctx.title()} was closed`, +}); + +// a related record, through the lookup column that holds its id +const account = await ctx.title('account_id'); +``` + +Three properties worth knowing: + +- **A formula `nameField` costs nothing extra.** It is evaluated server-side + against the record the hook is already firing on — the same expression, the + same evaluator and the same rounding a `GET` of that record would use, so the + title a hook writes and the title the UI shows cannot drift. +- **The related form costs exactly one `findOne`**, through your body's own read + channel — so it obeys the caller's scope and joins an open + `ctx.api.transaction`. That is why it requires `api.read` while the bare form + requires nothing: the token gates the read, and there is no read to gate. +- **It never falls back to the id.** No title resolvable ⇒ `null`. An id-shaped + string is a plausible-looking title to whatever renders it, so the platform + will not manufacture one; write your own fallback if you want one. + **There is no hashing capability — `crypto.hash` was removed in spec 17.** Until 17 the `crypto.hash` token was declared in `HookBodyCapability`, listed in this @@ -318,6 +353,7 @@ The extractor scans each body for known patterns and adds the matching capabilit | `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` | | `ctx.crypto.randomUUID` | `crypto.uuid` | | `ctx.log.info / warn / error / debug` | `log` | +| `*.title()` — the related-record form only; bare `ctx.title()` performs no read | `api.read` | When inference does not derive what a body needs, declare the tokens yourself by supplying `body` on the hook or action instead of a `handler`: diff --git a/packages/cli/src/utils/extract-hook-body.ts b/packages/cli/src/utils/extract-hook-body.ts index df5792d446..68d5665359 100644 --- a/packages/cli/src/utils/extract-hook-body.ts +++ b/packages/cli/src/utils/extract-hook-body.ts @@ -101,6 +101,21 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | ' // capability from a call that always threw is what let `os build` bless a // dead body — the inference was the amplifier, not the safety net. { rx: /ctx\.log\.(?:info|warn|error|debug)\b/, cap: 'log' }, + // [#11293] `ctx.title(field)` — the RELATED-record form, and only that form. + // `ctx.title()` resolves this record's title (formula included) from the + // state the hook is already firing on and performs no read at all, so it + // needs no capability and inferring one for it would tax the majority case + // with a grant it never exercises. The argument form costs exactly one + // `findOne` through the body's own read channel, which is the same read + // `ctx.api.object(...).findOne()` would do and gets the same token. + // + // `[^)\s]` after the paren is what distinguishes the two: `ctx.title()` and + // `ctx.title( )` do not match, `ctx.title('account_id')` does. Receiver-loose + // like the `.object(...)` patterns above, for the same reason — a local alias + // (`const t = ctx.title`) must not silently UNDER-infer, since that failure + // arrives as a sandbox refusal at run time, far from its cause. Over-inferring + // grants a token the body may not use, which the sandbox simply never checks. + { rx: /\.\s*title\s*\(\s*[^)\s]/, cap: 'api.read' }, ]; export interface ExtractedBody { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b29ca596ff..e1ff74f79b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1393,6 +1393,61 @@ function hydrateWriteFormulas( applyFormulaPlan(plan, records, execCtx); } +/** + * Materialize ONE declared `formula` field against a record already in hand — + * the read path's own evaluation, narrowed to a single field, with no round + * trip (#11293). + * + * ## Why this exists rather than a second evaluator + * + * A hook body cannot reach a formula field: `ctx.previous` / `ctx.input` carry + * STORED columns, and a formula is computed on read, so a body that wants the + * record's title has to rebuild the formula inline. Measured in the exemplar + * app: five inline reimplementations of a record title inside hook bodies, four + * of them re-composing a `nameField` that is a formula. Each copy can drift + * from the declaration it copies, silently — which is the whole defect, so the + * remedy must not itself be a copy. This calls + * {@link planFormulaProjection} + {@link applyFormulaPlan}: the same plan + * builder, the same `Expression` normalization (string shorthand → CEL + * envelope), the same `scale` rounding and the same evaluation scope the read + * and write paths use. One formula semantic, not a hook-path dialect (PD #12). + * + * ## Narrowed to one field ON PURPOSE + * + * `planFormulaProjection(schema, undefined)` — the shape `find` and + * {@link hydrateWriteFormulas} use — plans EVERY formula field on the schema + * and `ExpressionEngine.compile`s each one at planning stage. Asking for one + * title would then throw on an unrelated malformed formula elsewhere on the + * object. Passing `[field]` plans exactly the requested field, so the blast + * radius of a title lookup is the title's own declaration. + * + * ## Read-path parity, including how it fails + * + * Both failure modes are the read path's, unchanged: a formula that does not + * COMPILE throws (as it does on every `find` of the object), while a formula + * that compiles and does not EVALUATE yields `null` — `applyFormulaPlan`'s own + * `r.ok ? … : null`. A caller therefore cannot mistake "this title could not be + * computed" for a computed value. + * + * Returns `undefined` when `field` is not a declared formula field, which is + * how a caller tells "read the stored column instead" from "the formula + * produced nothing". Evaluates against a shallow COPY: `applyFormulaPlan` + * writes the value onto the record it is handed, and the records reaching here + * are the engine's own hook payloads, observed by everything downstream. + */ +export function evaluateFormulaField( + schema: unknown, + record: Record, + field: string, + execCtx?: ExecutionContext, +): unknown { + const { plan } = planFormulaProjection(schema as any, [field]); + if (plan.length === 0) return undefined; + const scratch: Record = { ...record }; + applyFormulaPlan(plan, [scratch], execCtx); + return scratch[field]; +} + /** * A hook body, as registered through {@link ObjectQL.registerHook} or bound * from metadata by `bindHooksToEngine`. diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index 1804dc1aa4..fe5d16aec2 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -746,6 +746,29 @@ function declaredFieldsFor(ctx: HookContext): Record | undefine * Copies, never mutates: `ctx.previous` and `ctx.input.data` are the engine's * own objects, observed by the handlers that run after this gate. */ +/** + * The record state THIS hook is firing for — stored ⊕ payload, materialized + * over the object's declared fields (#11293). + * + * The public name for {@link pickRecordPayload}, exported so a consumer that + * has to answer "what record is this?" gets the SAME answer the declarative + * `condition` gate gets. The runtime's `ctx.title()` seam is the first such + * consumer: a title composed from a different record state than the one + * `condition: "record.status == 'closed'"` evaluated would be two meanings of + * "this record" one line apart in the same hook — the drift PD #12 forbids, + * and precisely the drift this accessor exists to remove. + * + * A copy, never the engine's own object: {@link pickRecordPayload} builds a new + * record from `ctx.previous` and `ctx.input.data` rather than handing either + * out, so a caller cannot mutate the write through it. + */ +export function hookRecordState(ctx: HookContext): Record { + const record = pickRecordPayload(ctx); + return record && typeof record === 'object' && !Array.isArray(record) + ? (record as Record) + : {}; +} + function pickRecordPayload(ctx: HookContext): any { const input: any = ctx.input ?? {}; const payload: Record | undefined = diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index a76adadd18..d91d240a2a 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -146,6 +146,20 @@ export { wrapDeclarativeHook, HookConditionError } from './hook-wrappers.js'; // see the note above `HookConditionError` in `hook-wrappers.ts`. Its two members // described a batch-scoped `before*` dispatch that no longer exists. export type { WrapDeclarativeOptions } from './hook-wrappers.js'; +export { hookRecordState } from './hook-wrappers.js'; + +// Export record-title resolution (#11293) — "what is this record called?", +// answered from the object's own `nameField` declaration with a formula title +// evaluated server-side. The runtime's `ctx.title()` hook-body seam is built on +// exactly these; they are exported so it does not have to re-derive any of it. +export { + resolveRecordTitle, + resolveRelatedTitleTarget, + titleFieldOf, + RecordTitleFieldError, +} from './record-title.js'; +export type { RelatedTitleTarget } from './record-title.js'; +export { evaluateFormulaField } from './engine.js'; // Export Validation export { ValidationError, validateRecord } from './validation/record-validator.js'; diff --git a/packages/objectql/src/record-title.test.ts b/packages/objectql/src/record-title.test.ts new file mode 100644 index 0000000000..55b68dea0e --- /dev/null +++ b/packages/objectql/src/record-title.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #11293 — "what is this record called?", resolved from the object's own +// declaration instead of re-composed per hook. +// +// ## What these pins are protecting, and why the FORMULA arm is the centre +// +// The exemplar app carried FIVE inline reimplementations of a record title +// inside hook bodies, and in FOUR of the five the object's `nameField` points +// at a FORMULA (`display_title`, `full_name`); only one is a real column. A +// title accessor that reads stored columns only would therefore answer the +// wrong four of five — so the formula arm is not an extension of the column +// arm here, it is the default case and the column arm is the fallout. +// +// ## ⚠️ The vacuity trap this file is built to avoid +// +// An accessor test passes TRIVIALLY if the fixture's `nameField` happens to be +// a plain column, or if the formula happens to evaluate to the same string as +// some stored column — in both cases "the formula was evaluated" and "the +// stored value was read" are indistinguishable in the assertion. Every formula +// fixture below therefore composes a title that equals NO single stored column +// on the record, and `titleDiffersFromEveryStoredColumn` asserts exactly that +// as a control. Without it a naive column-reading implementation would sit +// green through the case it gets wrong. +// +// ## Rebuild is NOT load-bearing for this file +// +// Every import here is RELATIVE (`./record-title.js`, `./engine.js`), so vitest +// resolves them to this package's SOURCE. There is no `exports` hop to `dist/` +// and therefore no build artifact standing between an edit and this suite. The +// runtime-side seam suite (`packages/runtime/src/sandbox/`) is the opposite +// regime — it reaches this code through `@objectstack/objectql`, which resolves +// through `exports` to `dist/` — and says so in its own header. + +import { describe, it, expect } from 'vitest'; +import { + resolveRecordTitle, + resolveRelatedTitleTarget, + titleFieldOf, + RecordTitleFieldError, +} from './record-title.js'; +import { evaluateFormulaField } from './engine.js'; + +/** `crm_case` — the measured shape: `nameField` is a FORMULA. */ +const CASE = { + name: 'rt_case', + label: 'Case', + nameField: 'display_title', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + case_number: { name: 'case_number', label: 'No.', type: 'text' as const }, + subject: { name: 'subject', label: 'Subject', type: 'text' as const }, + status: { name: 'status', label: 'Status', type: 'text' as const }, + account_id: { name: 'account_id', label: 'Account', type: 'lookup' as const, reference: 'rt_account' }, + owner: { name: 'owner', label: 'Owner', type: 'user' as const }, + display_title: { + name: 'display_title', label: 'Title', type: 'formula' as const, + expression: { dialect: 'cel', source: 'record.case_number + " — " + record.subject' }, + }, + }, +}; + +/** `crm_opportunity` — the ONE measured object whose title is a real column. */ +const OPPORTUNITY = { + name: 'rt_opportunity', + label: 'Opportunity', + nameField: 'name', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + stage: { name: 'stage', label: 'Stage', type: 'text' as const }, + }, +}; + +const ACCOUNT = { + name: 'rt_account', + label: 'Account', + nameField: 'display_title', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + legal_name: { name: 'legal_name', label: 'Legal name', type: 'text' as const }, + region: { name: 'region', label: 'Region', type: 'text' as const }, + display_title: { + name: 'display_title', label: 'Title', type: 'formula' as const, + expression: { dialect: 'cel', source: 'record.legal_name + " (" + record.region + ")"' }, + }, + }, +}; + +/** + * THE VACUITY CONTROL. + * + * Asserts the resolved title is not merely one of the record's stored values + * echoed back — i.e. that the formula genuinely ran and genuinely composed. A + * column-reading implementation cannot satisfy this for any of the formula + * fixtures above. + */ +function titleDiffersFromEveryStoredColumn( + title: string | undefined, + record: Record, +): void { + expect(typeof title).toBe('string'); + const stored = Object.entries(record) + .filter(([k]) => k !== 'display_title' && k !== 'full_name') + .map(([, v]) => v); + expect(stored).not.toContain(title); + // …and it is not the id either, which is the value the defect reached for. + expect(title).not.toBe(record.id); +} + +describe('#11293 titleFieldOf — the ADR-0079 pointer', () => { + it('reads `nameField`', () => { + expect(titleFieldOf(CASE)).toBe('display_title'); + }); + + it('honours the deprecated `displayNameField` alias when `nameField` is absent', () => { + expect(titleFieldOf({ displayNameField: 'legacy_title', fields: {} })).toBe('legacy_title'); + }); + + it('`nameField` WINS over the deprecated alias — canonical first, no ambiguity', () => { + expect(titleFieldOf({ nameField: 'a', displayNameField: 'b', fields: {} })).toBe('a'); + }); + + it('undefined when the object declares no pointer at all', () => { + expect(titleFieldOf({ fields: {} })).toBeUndefined(); + expect(titleFieldOf(undefined)).toBeUndefined(); + }); +}); + +describe('#11293 resolveRecordTitle — a FORMULA nameField (the majority case)', () => { + const record = { + id: 'a1b2c3d4e5f6g7h8', + case_number: 'CASE-0042', + subject: 'Printer on fire', + status: 'open', + }; + + it('composes the declared formula server-side, with no round trip', () => { + const title = resolveRecordTitle(CASE, record); + expect(title).toBe('CASE-0042 — Printer on fire'); + }); + + it('CONTROL — the composed title equals no single stored column, so the formula really ran', () => { + titleDiffersFromEveryStoredColumn(resolveRecordTitle(CASE, record), record); + }); + + it('a formula that cannot EVALUATE answers absence, never a half-composed lie', () => { + // `subject` missing entirely: `applyFormulaPlan` records the evaluator's + // failure as `null`, which surfaces here as `undefined`. + const partial = { id: 'x', case_number: 'CASE-0043' }; + expect(resolveRecordTitle(CASE, partial)).toBeUndefined(); + }); + + it('never falls back to the record id', () => { + // No pointer at all — the one place an id fallback would be tempting. + const untitled = { name: 'rt_untitled', fields: { id: { name: 'id', type: 'text' as const } } }; + expect(resolveRecordTitle(untitled, { id: 'a1b2c3d4e5f6g7h8' })).toBeUndefined(); + }); +}); + +describe('#11293 resolveRecordTitle — a STORED-COLUMN nameField', () => { + it('reads the column straight off the record', () => { + expect(resolveRecordTitle(OPPORTUNITY, { id: 'o1', name: 'Acme — Phase 2', stage: 'won' })) + .toBe('Acme — Phase 2'); + }); + + it('absent column → undefined, not the empty string and not the id', () => { + expect(resolveRecordTitle(OPPORTUNITY, { id: 'o1', stage: 'won' })).toBeUndefined(); + }); + + it('a genuinely blank title is reported as blank, not as absence', () => { + // `''` and "no title pointer" are different facts about the object; a + // consumer that collapses them hides a misconfigured object behind a `??`. + expect(resolveRecordTitle(OPPORTUNITY, { id: 'o1', name: '' })).toBe(''); + }); + + it('a non-string scalar title is stringified rather than dropped', () => { + const numbered = { nameField: 'seq', fields: { seq: { name: 'seq', type: 'number' as const } } }; + expect(resolveRecordTitle(numbered, { seq: 42 })).toBe('42'); + }); +}); + +describe('#11293 evaluateFormulaField — narrowed to ONE field on purpose', () => { + it('an unrelated MALFORMED formula elsewhere on the object does not break the title', () => { + // `planFormulaProjection(schema, undefined)` — the shape `find` uses — + // compiles every formula on the schema at planning stage, so a title + // lookup planned that way would throw on a neighbour's typo. Narrowing to + // the requested field is what keeps a title's blast radius its own + // declaration. + const withBadNeighbour = { + ...CASE, + fields: { + ...CASE.fields, + broken: { + name: 'broken', label: 'Broken', type: 'formula' as const, + expression: { dialect: 'cel', source: 'record.a +++ ' }, + }, + }, + }; + const record = { id: 'c1', case_number: 'CASE-0044', subject: 'Still fine' }; + expect(resolveRecordTitle(withBadNeighbour, record)).toBe('CASE-0044 — Still fine'); + }); + + it('answers undefined for a field that is not a declared formula — the signal to read the column', () => { + expect(evaluateFormulaField(OPPORTUNITY, { name: 'Acme' }, 'name')).toBeUndefined(); + }); + + it('does NOT mutate the record it is handed', () => { + // `applyFormulaPlan` writes the computed value onto the record it receives, + // and the records reaching here are the engine's own hook payloads. + const record: Record = { id: 'c1', case_number: 'CASE-0045', subject: 'Untouched' }; + const before = JSON.stringify(record); + resolveRecordTitle(CASE, record); + expect(JSON.stringify(record)).toBe(before); + expect('display_title' in record).toBe(false); + }); +}); + +describe('#11293 resolveRelatedTitleTarget — a lookup column hands the body an id', () => { + const record = { id: 'c1', case_number: 'CASE-0046', subject: 'x', account_id: 'acc_9', owner: 'usr_3' }; + + it('resolves an author-declared lookup to its target object and the stored id', () => { + expect(resolveRelatedTitleTarget(CASE, record, 'account_id', 'test')) + .toEqual({ object: 'rt_account', id: 'acc_9' }); + }); + + it('resolves a `user` field whose target is fixed BY THE TYPE (no `reference` key)', () => { + // cloud#983: a raw `field.reference` read makes `{ type: 'user' }` look + // targetless even though `Field.user()` takes no target argument. + // `referenceTargetOf` is the single arbiter, and this is why it is used. + expect(resolveRelatedTitleTarget(CASE, record, 'owner', 'test')) + .toEqual({ object: 'sys_user', id: 'usr_3' }); + }); + + it('an EMPTY lookup is an ordinary state — undefined, not an error', () => { + expect(resolveRelatedTitleTarget(CASE, { ...record, account_id: null }, 'account_id', 'test')) + .toBeUndefined(); + expect(resolveRelatedTitleTarget(CASE, { ...record, account_id: '' }, 'account_id', 'test')) + .toBeUndefined(); + }); + + it('an ALREADY-EXPANDED reference still yields its id', () => { + const expanded = { ...record, account_id: { id: 'acc_9', legal_name: 'Acme' } }; + expect(resolveRelatedTitleTarget(CASE, expanded, 'account_id', 'test')) + .toEqual({ object: 'rt_account', id: 'acc_9' }); + }); + + it('an UNDECLARED field name throws — a typo must not read as "no title"', () => { + expect(() => resolveRelatedTitleTarget(CASE, record, 'acount_id', 'test')) + .toThrow(RecordTitleFieldError); + expect(() => resolveRelatedTitleTarget(CASE, record, 'acount_id', 'test')) + .toThrow(/not a declared field/); + }); + + it('a declared NON-reference field throws, naming its type and the remedy', () => { + expect(() => resolveRelatedTitleTarget(CASE, record, 'subject', 'test')) + .toThrow(/does not point at another record/); + }); + + it('the related object\'s own FORMULA title resolves the same way', () => { + const related = { id: 'acc_9', legal_name: 'Acme Industrial', region: 'EMEA' }; + const title = resolveRecordTitle(ACCOUNT, related); + expect(title).toBe('Acme Industrial (EMEA)'); + titleDiffersFromEveryStoredColumn(title, related); + }); +}); diff --git a/packages/objectql/src/record-title.ts b/packages/objectql/src/record-title.ts new file mode 100644 index 0000000000..8c2a0be1f6 --- /dev/null +++ b/packages/objectql/src/record-title.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Record title resolution (#11293) + * + * "What is this record called?" — answered once, server-side, from the object's + * own declaration, so that no consumer has to re-compose it. + * + * ## The gap this closes + * + * A hook body ships body-only: the CLI lowers `handler` to `body.source` and the + * runtime evaluates it in QuickJS with no module scope. Two consequences meet + * there. A body cannot reach a **formula** field (`ctx.previous` / `ctx.input` + * carry stored columns; a formula is computed on read), and nothing on `ctx` + * answers "what is this record called?" — no accessor for the object's + * `nameField`, and no way to resolve one for a **lookup-related** record, which + * arrives as a bare id. + * + * So the only way for a body to name a record in a sentence was to re-implement + * the object's title inline, per hook. Measured in the exemplar app: **five** + * inline reimplementations, and in **four of the five** the `nameField` is a + * FORMULA (`display_title`, `full_name`) — only one is a real column. Each copy + * duplicates a formula declared once on the object and drifts from it in + * silence. + * + * The measured consequence was not merely duplication. The cheap thing to write + * when there is no title accessor is `record.id` — the one identifier a body + * always holds — and that shipped: eight sites across four hooks put a raw + * primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in + * a demo org titled by a 16-character key. The AI-authoring angle is the sharp + * half: an agent writing a hook reaches for `${record.id}` precisely because it + * is the value in scope. This module exists to make the correct alternative + * closer to hand than the wrong one. + * + * ## Scope — design (a), and only (a) + * + * Maintainer ruling, 2026-08-23, live PM chat, verbatim: + * + * > 「10950 不考虑存量,其他接受你的建议」 + * + * ⇒ a **minimal `ctx` title accessor**: this record's `nameField` and a + * lookup-related record's `nameField`, with formula evaluated server-side. + * Two neighbouring designs were considered and are **NOT approved**: hydrating + * `nameField` into the hook pre-image (b), and general formula-field + * readability from hook bodies (c). Neither is built here, and neither is + * needed by what is: {@link resolveRecordTitle} reads one declared field — the + * title pointer — and evaluates it only when that pointer names a formula. + * + * ## What is deliberately NOT here + * + * **No id fallback.** A record with no resolvable title answers `undefined`, + * never its primary key. Falling back to the id would reintroduce, inside the + * platform, the exact defect the accessor exists to remove — and it would do it + * invisibly, since an id-shaped string is a perfectly plausible title to + * whatever renders it. A caller that genuinely wants a fallback writes one and + * owns it. + * + * **No second formula dialect.** Formula evaluation is delegated to + * `evaluateFormulaField` (`engine.ts`), which drives the read path's own plan + * builder and evaluator. A title composed here and a title read back from + * `GET /data/:object/:id` are the same expression evaluated the same way. + */ + +import { referenceTargetOf } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { evaluateFormulaField } from './engine.js'; + +/** + * The field that carries the object's primary title. + * + * [ADR-0079] `nameField` is the canonical primary-title pointer; + * `displayNameField` is the deprecated alias, still honoured. This is the SAME + * resolution `expandSearchOnAst` performs when it decides which field a bare + * `search` term matches against (`engine.ts`), stated once so the two cannot + * answer differently for one object. + */ +export function titleFieldOf(schema: unknown): string | undefined { + if (!schema || typeof schema !== 'object') return undefined; + const s = schema as { nameField?: unknown; displayNameField?: unknown }; + const pointer = typeof s.nameField === 'string' && s.nameField + ? s.nameField + : typeof s.displayNameField === 'string' && s.displayNameField + ? s.displayNameField + : undefined; + return pointer; +} + +/** + * Normalize a resolved title value to the string a message can carry. + * + * `null` and `undefined` are ABSENCE and answer `undefined` — a formula that + * did not evaluate lands here as `null` (`applyFormulaPlan`'s own + * `r.ok ? … : null`), so an uncomputable title can never be mistaken for a + * computed one. Everything else is stringified, `''` included: a record whose + * title really is blank is a different fact from a record that has no title + * pointer at all, and collapsing the two would hide a misconfigured object + * behind a `??` in every caller. + */ +function toTitle(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === 'string') return value; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') return undefined; + return String(value); +} + +/** + * This record's title, resolved from the object's declaration. + * + * Two shapes, one entry point — and the FORMULA shape is the majority case, not + * the exotic one (four of the five measured objects): + * + * - the title pointer names a **formula** field → evaluated against the record + * already in hand, with **no round trip**. `evaluateFormulaField` plans just + * that one field and runs the read path's evaluator over a copy; + * - the title pointer names a **stored column** → read straight off the + * record. + * + * The plain-column case falls out of the formula case rather than the reverse, + * which is the ordering the measurement demands: an accessor written + * column-first answers the wrong four of five. + * + * `undefined` when the object declares no title pointer, when the pointed-at + * field is absent from the record, or when a formula title did not evaluate. + * Never the record's id — see this module's header. + */ +export function resolveRecordTitle( + schema: unknown, + record: Record | null | undefined, + execCtx?: ExecutionContext, +): string | undefined { + const field = titleFieldOf(schema); + if (!field || !record || typeof record !== 'object') return undefined; + + // The formula leg first — `evaluateFormulaField` answers `undefined` for a + // field that is not a declared formula, which is exactly the signal to read + // the stored column instead. + const computed = evaluateFormulaField(schema, record, field, execCtx); + if (computed !== undefined) return toTitle(computed); + + return toTitle(record[field]); +} + +/** Where a related record's title has to be read from. */ +export interface RelatedTitleTarget { + /** The object the reference field points at. */ + object: string; + /** The related record's id, as stored on this record. */ + id: string; +} + +/** + * Thrown when a title lookup names a field that cannot point at a record. + * + * Loud rather than `undefined`, deliberately: a typo'd field name and an empty + * lookup column are opposite facts, and answering both with "no title" is how a + * body ends up silently unable to name anything. This is a plain `Error` and + * carries NO `code` — ADR-0112 makes `error.code` a closed wire vocabulary, and + * `rest-server.ts` promotes a thrown `.code` straight onto the response + * envelope, so adding one here would mint an unregistered wire code by side + * effect. + */ +export class RecordTitleFieldError extends Error { + override readonly name = 'RecordTitleFieldError'; + constructor(message: string) { + super(message); + } +} + +/** + * Resolve `field` on this record to the related record whose title is wanted. + * + * **Which target** is {@link referenceTargetOf} — the spec's single arbiter of + * "what does this reference field point at", the same one the `$expand` gate + * and the engine's own expansion ask. Reading `field.reference` raw here would + * reintroduce cloud#983: a `{ type: 'user' }` field's target is fixed BY THE + * TYPE (`sys_user`) and carries no `reference` of its own, so a raw read makes + * a fully-specified field look targetless. + * + * Answers `undefined` — not an error — when the field is declared and simply + * EMPTY on this record: an unset lookup is an ordinary state, and a hook that + * refuses the write because an optional relationship is blank would be worse + * than the hand-composed title it replaced. A field that is not declared, or + * that is not a reference type at all, throws {@link RecordTitleFieldError}. + */ +export function resolveRelatedTitleTarget( + schema: unknown, + record: Record | null | undefined, + field: string, + originLabel: string, +): RelatedTitleTarget | undefined { + const fields = (schema as { fields?: Record } | undefined)?.fields; + const def = fields && typeof fields === 'object' ? fields[field] : undefined; + if (!def) { + throw new RecordTitleFieldError( + `${originLabel}: '${field}' is not a declared field on this object, so it cannot name a related record`, + ); + } + const target = referenceTargetOf(def); + if (!target) { + const type = (def as { type?: unknown }).type; + throw new RecordTitleFieldError( + `${originLabel}: field '${field}' (type '${String(type)}') does not point at another record — ` + + `pass a lookup / master_detail / user / tree field, or call the accessor with no argument for this record's own title`, + ); + } + const raw = record && typeof record === 'object' ? record[field] : undefined; + // An EXPANDED reference (`$expand` overwrites the id in place with the + // related record) still carries its own id; a multi-value reference is not a + // single record and has no single title. + const id = typeof raw === 'object' && raw !== null && !Array.isArray(raw) + ? (raw as { id?: unknown }).id + : raw; + if (id === null || id === undefined || id === '') return undefined; + if (typeof id !== 'string' && typeof id !== 'number') return undefined; + return { object: target, id: String(id) }; +} diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 1d478edb97..25a9d72aaf 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -42,6 +42,18 @@ import type { Hook } from '@objectstack/spec/data'; import { HookBodySchema } from '@objectstack/spec/data'; import type { ScriptRunner, ScriptContext, ScriptResult } from './script-runner.js'; +// The record-title contract, imported rather than re-derived (#11293). The +// object's `nameField` pointer, a formula title's server-side evaluation and +// the "what does this reference field point at" rule all have exactly one +// owner — `@objectstack/objectql` — and this seam is a consumer of it, not a +// second implementation. That is the whole point: the defect being closed is a +// title re-composed per hook drifting from the declaration it copies, so a +// runtime-side copy of the same rules would reproduce the defect one layer up. +import { + hookRecordState, + resolveRecordTitle, + resolveRelatedTitleTarget, +} from '@objectstack/objectql'; interface FactoryOptions { ql: any; @@ -146,6 +158,125 @@ function buildBodyLogSurface( }; } +/** + * Build the `ctx.title(field?)` seam for one hook-body invocation (#11293). + * + * ## The measurement this shape is answering + * + * A hook body could not name a record. `ctx.previous` / `ctx.input` carry + * stored columns, a formula is computed on read, and nothing on `ctx` resolves + * the object's `nameField` — so a body composing "Case X was closed" had to + * re-implement the object's title inline. The exemplar app carried **five** + * such reimplementations, and in **four of the five** the `nameField` is a + * FORMULA (`display_title`, `full_name`); only one is a real column. So the + * formula case is the design centre here, not an extension of the column case: + * an accessor that only read stored columns would answer the wrong four of + * five, and the ruling's parenthetical — *formula evaluated server-side* — is + * load-bearing. + * + * What the absence actually produced was worse than duplication. The cheap + * thing to write with no title accessor is `record.id`, the one identifier + * always in scope, and it shipped into user-facing prose across four hooks. An + * AI writing a hook reaches for it for the same reason. Hence the whole point + * of this seam: put the correct answer closer to hand than the wrong one. + * + * ## Why the two forms cost different things + * + * - **`ctx.title()`** — this record. `hookRecordState` is the state the hook + * is ALREADY firing on (stored ⊕ payload, materialized over the declared + * fields — the very state the declarative `condition` gate evaluates), so + * the record is in hand. A formula title is evaluated against it in-process + * by `resolveRecordTitle`: **zero round trips**, even for the majority + * formula case. Measured cost is one CEL evaluation of one expression. + * - **`ctx.title('account_id')`** — a related record. A lookup column hands + * the body an id and nothing else, so this costs **exactly one `findOne`**, + * and no more: the engine's read path already materializes formula fields + * onto what it returns, so the related record arrives with its own + * `display_title` computed. There is no second pass and no per-field + * round trip. + * + * That asymmetry is why the VM-facing wire gates only the second form behind + * `api.read` (see `installCtx`): the token gates a read, and the first form has + * no read to gate. + * + * ## Why the read goes through `ctx.api` and not the engine directly + * + * `api` is handed in per call by the installer rather than closed over, so it + * is the transaction-scoped context whenever a body-opened + * `ctx.api.transaction` is in flight. A related-title read that bypassed it + * would ask the pool for a second connection — invisible on a roomy pool, a + * deadlock on `pool max=1`, which is SQLite and therefore the default + * datasource for `objectstack dev`. It also means the read obeys the caller's + * scope and field-level security exactly as the body's own + * `ctx.api.object(...).findOne` would: the accessor is a convenience over the + * read channel a body already has, never a privilege escalation around it. + */ +function buildTitleSurface( + engineCtx: any, + ql: any, + origin: { kind: 'hook' | 'action'; name: string }, +): ScriptContext['title'] { + const label = `ctx.title (${origin.kind} '${origin.name}')`; + + return async (field: string | undefined, api: unknown): Promise => { + const objectName = typeof engineCtx?.object === 'string' ? engineCtx.object : undefined; + if (!objectName || !ql || typeof ql.getObject !== 'function') { + throw new Error(`${label}: no object schema is reachable from this hook context`); + } + const schema = ql.getObject(objectName); + if (!schema) { + throw new Error(`${label}: object '${objectName}' is not registered`); + } + const record = hookRecordState(engineCtx); + const execCtx = executionContextFromHook(engineCtx); + + if (field === undefined) { + return resolveRecordTitle(schema, record, execCtx); + } + + const target = resolveRelatedTitleTarget(schema, record, field, label); + // A declared but EMPTY reference: an ordinary state, answered as absence. + if (!target) return undefined; + + const source = (api ?? engineCtx?.api) as { object?: (n: string) => any } | undefined; + if (!source || typeof source.object !== 'function') { + throw new Error(`${label}: no read channel is available to resolve '${field}'`); + } + const related = await source.object(target.object).findOne({ where: { id: target.id } }); + if (!related) return undefined; + // The read path already evaluated the related object's formula fields onto + // this row; `resolveRecordTitle` re-derives the SAME value from the same + // declaration rather than trusting whichever repo facade answered, so a + // minimal embedder's `findOne` cannot quietly downgrade a formula title to + // absence. + return resolveRecordTitle(ql.getObject(target.object), related as Record, execCtx); + }; +} + +/** + * The evaluation scope a formula title is computed in, derived from what the + * hook context actually carries. + * + * `HookContext` declares no `timezone` and no `ExecutionContext`, so this is the + * closest scope reachable on this path rather than a copy of the read path's: + * `os.user` / `os.org` resolve from the hook session, and a timezone-sensitive + * formula falls back to the server default exactly as it does for every other + * hook-layer CEL evaluation. Stated rather than silently approximated — a + * `nameField` formula reading `os.user` is the realistic case and it works; + * one reading a localized date is the case that can differ from a REST read, + * and an author is owed that fact. + */ +function executionContextFromHook(engineCtx: any): any { + const session = engineCtx?.session; + if (!session || typeof session !== 'object') return undefined; + return { + userId: session.userId, + tenantId: session.organizationId, + positions: Array.isArray(session.positions) ? session.positions : undefined, + isSystem: session.isSystem, + }; +} + export function hookBodyRunnerFactory( runner: ScriptRunner, opts: FactoryOptions, @@ -170,6 +301,7 @@ export function hookBodyRunnerFactory( engineCtx, opts.ql, buildBodyLogSurface(opts, { kind: 'hook', name: hook.name }), + buildTitleSurface(engineCtx, opts.ql, { kind: 'hook', name: hook.name }), ); try { opts.logger?.debug?.('[BodyRunner] hook fired', { appId: opts.appId, hook: hook.name }); @@ -413,6 +545,7 @@ function buildSandboxContext( engineCtx: any, ql: any, log: ScriptContext['log'], + title?: ScriptContext['title'], ): ScriptContext { // `input` and `previous` are the engine's own spellings, and the only ones: // `HookContextSchema` (`packages/spec/src/data/hook.zod.ts`) declares neither a @@ -449,6 +582,11 @@ function buildSandboxContext( // `HookContextSchema` never declared, so the `['log']` capability resolved // `undefined` and every body log line vanished. See {@link buildBodyLogSurface}. log, + // [#11293] Hook face only. The action face has its own record shape + // (`ctx.record`, a pre-fetched read-only snapshot) and its own dispatch + // sites; widening the accessor to it is a separate capability call and is + // deliberately not taken here — the ruling names hook bodies. + title, crypto: globalThis.crypto, }; } diff --git a/packages/runtime/src/sandbox/hook-ctx-title.integration.test.ts b/packages/runtime/src/sandbox/hook-ctx-title.integration.test.ts new file mode 100644 index 0000000000..609b99948b --- /dev/null +++ b/packages/runtime/src/sandbox/hook-ctx-title.integration.test.ts @@ -0,0 +1,333 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #11293 — a lowered hook body can name a record. +// +// Everything here runs a REAL {@link ObjectQL} engine through the REAL +// {@link QuickJSScriptRunner} via {@link hookBodyRunnerFactory}, because the +// claim under test is about what a body-only hook can reach at INVOCATION time +// inside the VM — not about a host function's return value. A unit test of the +// seam would pass on a `ctx.title` that `installCtx` never wires onto the VM, +// which is exactly the `crypto.hash` / `ctx.log` shape this package has been +// bitten by twice (#4391, #7448): declared, inferred, documented, never +// installed. +// +// ## ⚠️ REBUILD IS LOAD-BEARING FOR THIS FILE — determined, not assumed +// +// `packages/runtime/vitest.config.ts` aliases `@objectstack/core`, +// `platform-objects`, `rest`, `spec`, `types`, `service-job` and +// `service-package` to source — and NOT `@objectstack/objectql`, which is +// registered in `KNOWN_UNALIASED_TEST_IMPORTS` (`scripts/check-test-source-alias.mjs`) +// for this package. So the `ObjectQL` import below AND the +// `resolveRecordTitle` / `hookRecordState` / `resolveRelatedTitleTarget` +// imports inside `body-runner.ts` all resolve through `exports` to +// `objectql/dist`. An edit to `packages/objectql/src` that is not rebuilt is +// INVISIBLE here — and the dangerous direction is that an ablation of the +// objectql half stays GREEN, certifying a pin that measured a stale artifact. +// `pnpm --filter @objectstack/objectql build` before running this file. +// +// The sibling suite `packages/objectql/src/record-title.test.ts` is the +// opposite regime (relative imports → source, no rebuild owed) and says so. +// +// ## ⚠️ The vacuity trap +// +// Four of the five record titles measured in the exemplar app are FORMULA +// fields, so a `nameField` accessor that only read stored columns would answer +// the wrong four of five — and a test whose fixture title happened to equal a +// stored column could not tell the two implementations apart. Every formula +// fixture below composes a title that matches NO single stored column, and the +// control asserts that directly. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { hookBodyRunnerFactory } from './body-runner.js'; +import { QuickJSScriptRunner } from './quickjs-runner.js'; + +/** + * A minimal driver that also COUNTS reads per object, so "resolving a formula + * title costs no round trip" is a measurement rather than a claim. + */ +function makeCountingDriver() { + const rows = new Map>(); + const reads: string[] = []; + const storeFor = (o: string) => { + let s = rows.get(o); + if (!s) { s = new Map(); rows.set(o, s); } + return s; + }; + const matches = (all: any[], ast: any) => { + const id = ast?.where?.id; + if (typeof id === 'string') return all.filter((r) => r.id === id); + return all; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + reads.push(object); + return matches(Array.from(storeFor(object).values()), ast); + }, + async findOne(object: string, ast: any) { + reads.push(object); + return matches(Array.from(storeFor(object).values()), ast)[0] ?? null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${storeFor(object).size + 1}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const row = { ...storeFor(object).get(id), ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async syncSchema() {}, + async beginTransaction() { return { __trx: 1 }; }, + async commit() {}, async rollback() {}, + /** Seed straight into storage — no engine verb, so no read is counted. */ + seed(object: string, row: Record) { storeFor(object).set(String(row.id), row); }, + }; + return { driver, reads }; +} + +/** `crm_case` — `nameField` is a FORMULA (four of the five measured objects). */ +const CASE_OBJECT = { + name: 'tc_case', + label: 'Case', + nameField: 'display_title', + fields: { + case_number: { name: 'case_number', label: 'No.', type: 'text' as const }, + subject: { name: 'subject', label: 'Subject', type: 'text' as const }, + status: { name: 'status', label: 'Status', type: 'text' as const }, + account_id: { name: 'account_id', label: 'Account', type: 'lookup' as const, reference: 'tc_account' }, + notified: { name: 'notified', label: 'Notified', type: 'text' as const }, + display_title: { + name: 'display_title', label: 'Title', type: 'formula' as const, + expression: { dialect: 'cel', source: 'record.case_number + " — " + record.subject' }, + }, + }, +}; + +/** `crm_opportunity` — the one measured object whose title is a real column. */ +const OPPORTUNITY_OBJECT = { + name: 'tc_opportunity', + label: 'Opportunity', + nameField: 'name', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + notified: { name: 'notified', label: 'Notified', type: 'text' as const }, + }, +}; + +/** The lookup target — its OWN title is a formula too. */ +const ACCOUNT_OBJECT = { + name: 'tc_account', + label: 'Account', + nameField: 'display_title', + fields: { + legal_name: { name: 'legal_name', label: 'Legal name', type: 'text' as const }, + region: { name: 'region', label: 'Region', type: 'text' as const }, + display_title: { + name: 'display_title', label: 'Title', type: 'formula' as const, + expression: { dialect: 'cel', source: 'record.legal_name + " (" + record.region + ")"' }, + }, + }, +}; + +const SYS = { isSystem: true } as any; + +describe('#11293 ctx.title() inside a real QuickJS hook body', () => { + let engine: ObjectQL; + let reads: string[]; + let seed: (object: string, row: Record) => void; + + const wire = async ( + object: string, + source: string, + capabilities: string[] = [], + ) => { + engine = new ObjectQL(); + const d = makeCountingDriver(); + reads = d.reads; + seed = (o, r) => d.driver.seed(o, r); + engine.registerDriver(d.driver, true); + await engine.init(); + for (const o of [CASE_OBJECT, OPPORTUNITY_OBJECT, ACCOUNT_OBJECT]) { + engine.registry.registerObject(o as any, 'test'); + } + engine.setDefaultBodyRunner( + hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'test' }), + ); + bindHooksToEngine( + engine, + [{ + name: 'tc_title_hook', + object, + events: ['beforeUpdate'], + body: { language: 'js', source, capabilities }, + } as any], + { packageId: 'test' }, + ); + }; + + beforeEach(() => { reads = []; }); + + it('resolves a FORMULA nameField — the majority case — from inside the body', async () => { + // The body writes what it resolved into a stored column, so the assertion + // reads a value that really crossed the VM boundary rather than a host + // return value the sandbox may never have seen. + await wire('tc_case', "ctx.input.notified = 'closed: ' + (await ctx.title());"); + seed('tc_case', { id: 'c1', case_number: 'CASE-0042', subject: 'Printer on fire', status: 'open' }); + + const updated: any = await engine.update( + 'tc_case', { id: 'c1', status: 'closed' }, { context: SYS }, + ); + expect(updated.notified).toBe('closed: CASE-0042 — Printer on fire'); + }, 30000); + + it('CONTROL — that title equals no single stored column, so the formula genuinely ran', async () => { + await wire('tc_case', "ctx.input.notified = await ctx.title();"); + const stored = { id: 'c2', case_number: 'CASE-0043', subject: 'Badge reader down', status: 'open' }; + seed('tc_case', stored); + + const updated: any = await engine.update( + 'tc_case', { id: 'c2', status: 'closed' }, { context: SYS }, + ); + expect(updated.notified).toBe('CASE-0043 — Badge reader down'); + // A stored-column-only implementation could only ever have produced one of + // these values, or the id. + expect(Object.values(stored)).not.toContain(updated.notified); + expect(updated.notified).not.toBe('c2'); + }, 30000); + + it('MEASUREMENT — a formula title for THIS record costs no extra read', async () => { + await wire('tc_case', "ctx.input.notified = await ctx.title();"); + seed('tc_case', { id: 'c3', case_number: 'CASE-0044', subject: 'Coffee machine', status: 'open' }); + + reads.length = 0; + await engine.update('tc_case', { id: 'c3', status: 'closed' }, { context: SYS }); + + // Whatever the write itself reads, NONE of it is attributable to the title: + // the same update with a body that never calls `ctx.title()` reads exactly + // as much. That equality is the measurement — an absolute count would just + // pin the update path's own behaviour. + const withTitle = reads.length; + + await wire('tc_case', "ctx.input.notified = 'x';"); + seed('tc_case', { id: 'c3', case_number: 'CASE-0044', subject: 'Coffee machine', status: 'open' }); + reads.length = 0; + await engine.update('tc_case', { id: 'c3', status: 'closed' }, { context: SYS }); + + expect(withTitle).toBe(reads.length); + }, 30000); + + it('resolves a STORED-COLUMN nameField the same way', async () => { + await wire('tc_opportunity', "ctx.input.notified = await ctx.title();"); + seed('tc_opportunity', { id: 'o1', name: 'Acme — Phase 2' }); + + const updated: any = await engine.update( + 'tc_opportunity', { id: 'o1', notified: 'pending' }, { context: SYS }, + ); + expect(updated.notified).toBe('Acme — Phase 2'); + }, 30000); + + it('resolves a LOOKUP-RELATED record\'s title — the case a body only holds an id for', async () => { + await wire( + 'tc_case', + "ctx.input.notified = 'account: ' + (await ctx.title('account_id'));", + ['api.read'], + ); + seed('tc_account', { id: 'acc_9', legal_name: 'Acme Industrial', region: 'EMEA' }); + seed('tc_case', { + id: 'c4', case_number: 'CASE-0045', subject: 'Shipment late', + status: 'open', account_id: 'acc_9', + }); + + const updated: any = await engine.update( + 'tc_case', { id: 'c4', status: 'closed' }, { context: SYS }, + ); + // The RELATED object's own title is a formula too — so this arm also proves + // the related read is not just echoing a stored column back. + expect(updated.notified).toBe('account: Acme Industrial (EMEA)'); + expect(updated.notified).not.toContain('acc_9'); + }, 30000); + + it('MEASUREMENT — a related title costs exactly ONE extra read, formula included', async () => { + // The related object's `nameField` is itself a FORMULA, and the engine's + // read path materializes formula fields onto what it returns — so the one + // `findOne` is the whole cost. There is no second pass and no per-field + // round trip, which is the number that decides whether this accessor is + // usable in a hook body's hot path. + const baseline = "ctx.input.notified = 'x';"; + const withRelated = "ctx.input.notified = await ctx.title('account_id');"; + const measure = async (source: string, caps: string[]) => { + await wire('tc_case', source, caps); + seed('tc_account', { id: 'acc_9', legal_name: 'Acme Industrial', region: 'EMEA' }); + seed('tc_case', { + id: 'c9', case_number: 'CASE-0050', subject: 'Cost', status: 'open', account_id: 'acc_9', + }); + reads.length = 0; + await engine.update('tc_case', { id: 'c9', status: 'closed' }, { context: SYS }); + return [...reads]; + }; + + const base = await measure(baseline, []); + const related = await measure(withRelated, ['api.read']); + + expect(related.length - base.length).toBe(1); + // …and the one extra read is of the TARGET object, not a re-read of this one. + expect(related.filter((o) => o === 'tc_account').length).toBe(1); + expect(base.filter((o) => o === 'tc_account').length).toBe(0); + }, 30000); + + it('an EMPTY lookup answers null rather than throwing — an unset relationship is ordinary', async () => { + await wire( + 'tc_case', + "var t = await ctx.title('account_id'); ctx.input.notified = (t === null ? 'none' : t);", + ['api.read'], + ); + seed('tc_case', { id: 'c5', case_number: 'CASE-0046', subject: 'No account', status: 'open' }); + + const updated: any = await engine.update( + 'tc_case', { id: 'c5', status: 'closed' }, { context: SYS }, + ); + expect(updated.notified).toBe('none'); + }, 30000); + + it('the related form is GATED by api.read; the bare form needs no capability', async () => { + // Gate arm: same body, capability withheld. + await wire('tc_case', "ctx.input.notified = await ctx.title('account_id');", []); + seed('tc_account', { id: 'acc_9', legal_name: 'Acme Industrial', region: 'EMEA' }); + seed('tc_case', { + id: 'c6', case_number: 'CASE-0047', subject: 'Gated', status: 'open', account_id: 'acc_9', + }); + + await expect( + engine.update('tc_case', { id: 'c6', status: 'closed' }, { context: SYS }), + ).rejects.toThrow(/capability 'api\.read' not granted/); + + // CONTROL arm — the SAME hook with NO capabilities resolving THIS record's + // title succeeds. Without it, the rejection above could equally be produced + // by a `ctx.title` that is broken for every call, and the gate would be + // measuring nothing. + await wire('tc_case', "ctx.input.notified = await ctx.title();", []); + seed('tc_case', { id: 'c7', case_number: 'CASE-0048', subject: 'Ungated', status: 'open' }); + const updated: any = await engine.update( + 'tc_case', { id: 'c7', status: 'closed' }, { context: SYS }, + ); + expect(updated.notified).toBe('CASE-0048 — Ungated'); + }, 30000); + + it('a typo\'d field name is REFUSED, not answered with a missing title', async () => { + await wire('tc_case', "ctx.input.notified = await ctx.title('acount_id');", ['api.read']); + seed('tc_case', { id: 'c8', case_number: 'CASE-0049', subject: 'Typo', status: 'open' }); + + await expect( + engine.update('tc_case', { id: 'c8', status: 'closed' }, { context: SYS }), + ).rejects.toThrow(/not a declared field/); + }, 30000); +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 37f4a74043..8e57fb5b35 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -687,6 +687,77 @@ export class QuickJSScriptRunner implements ScriptRunner { vm.setProp(ctxObj, 'crypto', cryptoObj); cryptoObj.dispose(); + // ── [#11293] `ctx.title(field?)` — "what is this record called?" ──────── + // + // Installed only when the host built the seam (hook bodies; the action face + // does not). It settles through the SAME deferred-promise + pump mechanism + // every other host call uses — never `newAsyncifiedFunction` — because a + // body will routinely `await ctx.title()` next to an `await + // ctx.api.object(...).findOne()`, and asyncify forbids a second unwind + // while one is in flight (ADR-0102 D2, the defect that moved this runner + // off the asyncify build). + // + // ## Why the capability gate is on the ARGUMENT, not on the function + // + // The two forms cost different things, and the token gates the cost: + // + // ctx.title() → this record. Resolved from the record state + // the hook is already firing on, formula title + // included. NO read, so no `api.read`. + // ctx.title('account_id') → a related record. Exactly one `findOne` + // through the body's own read channel, so + // `api.read` — the same token the equivalent + // hand-written `ctx.api.object(...).findOne()` + // needs, gating the same read. + // + // Requiring `api.read` for the no-argument form would tax the majority case + // with a grant it never exercises, and the whole point of this accessor is + // that naming a record correctly must be CHEAPER to reach than `record.id` + // — which is what a body writes when the right answer is inconvenient. + // Declared = enforced, per form. + if (typeof ctx.title === 'function') { + const titleFn = vm.newFunction('title', (fieldH) => { + const field = fieldH === undefined ? undefined : vm.dump(fieldH); + // `vm.dump`, like every other host bridge here — `getString` on a + // non-string handle coerces INSIDE the VM and would turn a mistaken + // `ctx.title({})` into a lookup for a field literally named + // "[object Object]". + const fieldName = field === undefined || field === null ? undefined : String(field); + if (fieldName !== undefined && !caps.has('api.read')) { + throwSandboxFault( + vm, + `capability 'api.read' not granted to ${origin.kind} '${origin.name}' ` + + `(called ctx.title('${fieldName}'), which reads the related record). ` + + `ctx.title() with no argument needs no capability.`, + ); + } + const deferred = vm.newPromise(); + deferreds.add(deferred); + void (async () => { + try { + // Read `txState` HERE, at call time: a related-title read must ride + // a transaction the body opened after this function was installed, + // or it asks the pool for a second connection — a deadlock on + // `pool max=1` (SQLite, the `objectstack dev` default). + const source = txState.api ?? (ctx.api as Record | undefined); + const value = await ctx.title!(fieldName, source); + if (!vm.alive) return; + const h = jsonToHandle(vm, value ?? null); + deferred.resolve(h); + h.dispose(); + } catch (err) { + if (!vm.alive) return; + const errH = hostErrorToVm(vm, err); + deferred.reject(errH); + errH.dispose(); + } + })(); + return deferred.handle; + }); + vm.setProp(ctxObj, 'title', titleFn); + titleFn.dispose(); + } + vm.setProp(vm.global, '__ctx', ctxObj); ctxObj.dispose(); diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index cbb7dce8b0..8bc5c2d643 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -240,6 +240,40 @@ export interface ScriptContext { /** Engine-side `result` (only set for after* hooks). */ result?: unknown; api?: unknown; + /** + * Host seam behind the VM's `ctx.title(field?)` — "what is this record + * called?", answered from the object's own `nameField` declaration (#11293). + * + * Hook bodies only. `installCtx` (quickjs-runner.ts) is the SOLE caller and + * supplies both arguments; a body sees a one-argument `ctx.title(field?)`. + * + * - `field === undefined` → this record's title. Resolved from the record + * state the hook is already firing on (`hookRecordState`, the same state + * the declarative `condition` gate evaluates), so it performs **no I/O** + * at all — including when the title is a formula, which is the majority + * case. + * - `field` names a reference column → that related record's title. This + * one costs a single `findOne`, which is why the VM-facing wire gates it + * behind `api.read` while the no-argument form needs no capability: the + * token gates the read, and the no-argument form has no read to gate. + * + * `api` is the read channel the installer resolves AT CALL TIME — the + * transaction-scoped context while a body-opened `ctx.api.transaction` is in + * flight, the base `ctx.api` otherwise. It is a parameter rather than a + * closure because the seam is built once per invocation, in `body-runner.ts`, + * before any transaction can exist; threading it is what keeps a related-title + * read on the transaction's own connection instead of asking the pool for a + * second one, which on a `pool max=1` datasource (SQLite, the default for + * `objectstack dev`) is a deadlock rather than a slow path. + * + * Resolves `undefined` — never the record's id — when there is no title to + * give. See `record-title.ts` in `@objectstack/objectql` for why an id + * fallback is refused. Inside the VM that absence arrives as `null`, which is + * what every host value crossing this boundary becomes when it is not + * JSON-representable; `ctx.title() ?? '…'` reads the same either way, and + * `=== undefined` does not, so the VM-facing contract is stated as `null`. + */ + title?: (field: string | undefined, api: unknown) => Promise; /** * Host-provided log seam for the `['log']` capability — four levels, matching * the four `installCtx` (quickjs-runner.ts) wires onto the VM's `ctx.log` and diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 8a5824704e..b914c0ed9c 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -371,6 +371,7 @@ The sandbox is handed a **JSON snapshot** of these (built by | `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. | | `ctx.log` | `{ info, warn, error }` | Gated by `log`. Call **`ctx.log.info(msg, data?)`** — `ctx.log` is an **object, not** callable as `ctx.log(msg)`. Emission is **best-effort** (see Troubleshooting). | | `ctx.crypto` | `{ randomUUID }` | Gated by `crypto.uuid`. | +| `ctx.title` | `(field?) => Promise` | **Name the record instead of printing its id.** `await ctx.title()` resolves this object's `nameField` — including when it is a **formula**, evaluated server-side, with no extra read. `await ctx.title('account_id')` resolves the related record's title through a lookup column (one `findOne`, gated by `api.read`; the no-argument form needs no capability). `null` when there is no title — it never falls back to the id. | (Action bodies additionally receive `ctx.recordId` and `ctx.record`, and their wrap is `(async (input, ctx) => { … })(input, ctx)` — the action params arrive as @@ -442,7 +443,7 @@ set of legal tokens (`HookBodyCapability`) is exactly five: | Token | Unlocks | |:--|:--| -| `api.read` | `ctx.api.object(n).find` / `findOne` / `count` | +| `api.read` | `ctx.api.object(n).find` / `findOne` / `count`; also `ctx.title('')`, which reads that related record. Plain `ctx.title()` reads nothing and needs no token. | | `api.write` | `ctx.api.object(n).insert` / `update` / `delete` / `upsert` | | `api.transaction` | `ctx.api.transaction(async () => { … })` — runs the callback's `ctx.api` ops in **one driver transaction** (commit on return, rollback on throw). Pair it with `api.write`. | | `crypto.uuid` | `ctx.crypto.randomUUID()` |