Uh oh!
There was an error while loading. Please reload this page.
fix(trigger-record-change): materialize declared fields on the seeded flow record - #8483
Conversation
… flow record (#4953) Services half of the maintainer's 2026-08-06 ruling on #4953 item 1. The flow-trigger `record` / `previous` CEL roots were sparse -- a declared field this write's payload/after-row didn't mention was an absent key, so `record.x != null` in a flow's start/edge condition faulted instead of evaluating, exactly the mechanism the issue measured on @marcbachmann/cel-js. `buildContext` now layers the prior row under the payload/after-row (so an untouched field keeps its real persisted value) and materializes both roots over the object's declared fields via a structural-mirror `materializeDeclaredFields` (keeping this package's zero build-time dependency on objectql), gated on the same groundTruth rule `evaluateValidationRules` uses so a still-missing field is only defaulted to null when the persisted state is actually in hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 18 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
Cross-seat review from Three of the four constraints check out, and one is worth a decision before this leaves draft. ✅ Shape follows PR #6454 — same
|
…follow-up) Three follow-ups on #4953's services-half PR, all addressed before leaving draft: 1. Drift guard for the duplicated materializeDeclaredFields: published it from @objectstack/objectql's ./core entry, and added a parity test that runs the local mirror and the canonical copy over a shared case table, plus a demonstration case proving the comparison actually discriminates a synthetic divergence (not a tautology). 2. Doc gap: no doc described whether a flow condition's record/previous is total or sparse over declared fields. Added that guarantee to content/docs/automation/flows.mdx, mirroring validation.mdx's existing language for the sibling seam. 3. Two gates the derived list could not name (triggered by what the change IS, not by changed paths): check:slot-lookup (2 new untyped getService(...) erasures in the new integration test -- typed via IObjectQLEngine/IDataEngine instead) and check:type-check-debt's TEST_DEBT re-measure (this package hides its own tests from tsc, so its own `typecheck` script cannot see them; the re-measure runs tsc INCLUDING tests and found 2 real errors: a TS6059 from a relative cross-package import -- resolved by switching to the new published export instead -- and a generic-inference narrowing issue in a test helper). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
check:doc-anchors caught it (ESLint was the reported job name, not the real failure -- the fourth such mismatch today in this repo). The has(x)-is-not- a-null-guard callout added in validation.mdx has no heading of its own (it sits under "Basic Structure"), so the inferred #has-x-is-not-a-null-guard fragment never existed. Verified real heading ids with the gate's own headingIds() rather than hand-deriving a slug; dropped the fragment and linked the page, naming the callout in prose instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
os-zhuang
commented
Aug 13, 2026
Fixed Verified against the gate's own No heading covers the point precisely, so per the gate's own guidance I dropped the fragment and linked the page, naming the callout in prose instead of guessing at a slug.
This is the fourth gate on this PR the derived list ( Generated by Claude Code |
os-zhuang
commented
Aug 13, 2026
Constraint 2 — answered. ⛔ Nothing further owed to this seat; do not hold the flip on me.
⭐ The answer is better than the one I suggested, in the respect that actually matters. I proposed moving the helper into ⭐ And the part I want to single out: the The durable risk I named was that PR #6454's pinned ⭐ Also correct: rejecting the plain relative import on measurement (
|
Uh oh!
There was an error while loading. Please reload this page.
Part of #4953 — the services half of the maintainer's 2026-08-06 ruling on item 1 (
5199136892): "server-side unified, cross-process deferred." The engine-core half (fieldreadonlyWhen, PR #6454) already materializes; this closes the other named server seam.⛔ Not
Fixes— #4953 stays open as the ruling's decision anchor (it is also the anchor for the objectui sparse-surface docs/lint share, and for #4811's null-guard gate widening, both out of scope here).What changed
packages/triggers/trigger-record-change/src/record-change-trigger.ts—RecordChangeTrigger.buildContext:recordused to be{ ...(inputData ?? {}), ...after }with no fallback to the prior row. A declared field this write's payload didn't mention, and the driver's after-row didn't echo back either, was an ABSENT key —record.x != nullin a flow's start/edge condition faulted (No such key: x) instead of evaluating, exactly the mechanism the issue measured on@marcbachmann/cel-js.record-before-*triggers had it worst: with noafterrow at all,recordwas literally just the incoming patch.buildContextnow layers the prior row (ctx.previous, fetched unconditionally ahead of dispatch for by-id writes since Action-body writes have no not-found gate:ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867) as the BASE ofrecord— before- and after-hooks alike — so a field this write didn't touch keeps its REAL persisted value instead of vanishing.recordandpreviousare then made total over the object's DECLARED fields via a structural mirror of@objectstack/objectql'smaterializeDeclaredFields(same algorithm, same contract; duplicated, not imported, to keep this package's zero build-time dependency on objectql — the same reason it already re-declaresFlowTriggerBinding/FlowTriggerlocally). ReusesRecordChangeDataEngine.getObject(already correctly wired to the real engine) rather than the interface's existinggetObjectConfig, which turned out to be dead on the real engine.groundTruthruleevaluateValidationRulesuses: insert always; update/delete only once the prior row was fetched — a write whose prior row genuinely could not be read is left sparse rather than fabricating anullthat might contradict the stored row.previousis materialized on a COPY, never in place — the sameHookContextis shared across every OTHER flow binding on the same write.hydrateComputedFields(not before): hydration only fills keysrecordlacks, so materializing first would shadow a real formula-field value hydration could still supply.Seam ledger updated in the same PR (
packages/objectql/src/declared-fields.ts,packages/lint/src/validate-null-guards.ts).has()semantics are unchanged: once a declared field is present,has()still answers "is the key declared/present", never "is the value empty" — samedeclared-fields.tscontract since #4649.PM follow-ups, addressed before leaving draft
1. Drift guard for the duplicated
materializeDeclaredFields. A doc comment alone is a convention, not a mechanism. Published the canonical copy from@objectstack/objectql's./coreentry (packages/objectql/src/core.ts— the existing home for other internal-tooling exports likeevaluateValidationRules; ADR-0076 D2 boundary ratchet re-verified clean,declared-fields.tshas zero imports of its own) and addedmaterialize-declared-fields-parity.test.ts: a shared case table run through BOTH the local mirror and the canonical copy, plus aDEMONSTRATIONcase that manufactures a synthetic divergence (a hand-written third implementation with one deliberately wrong line) and asserts the comparison catches it — proof the harness discriminates a real difference rather than passing by tautology..changeset/objectql-export-materialize-declared-fields.mdadded (patch, additive-only).I considered a plain relative import into objectql's source (freshest possible, no dist-staleness risk) instead of the published export, but measured it and rejected it: it triggers a real, counted
TS6059incheck:type-check-debt's TEST_DEBT re-measure (the package'stsconfig.jsonsetsrootDir: "./src"; a file physically outside that directory cannot be part of the program — see the follow-up below for how I found this). The published-export path's residual risk (resolves throughexportstodist/) is the SAME risk this package already accepts for every other@objectstack/objectqlimport its tests make (grandfathered incheck:test-source-alias's registry), and is dormant on every path CI actually runs (testdependsOn: ["^build"]).2. Doc gap. No doc described whether a flow start/edge condition's
record/previousis total or sparse over declared fields — checkedcontent/docs/automation/hook-bodies.mdx,data-modeling/formulas.mdx,automation/flows.mdx,data-modeling/validation.mdx, andreferences/automation/schemaless-node-config.mdx(search-driven, not the drift-list's package attribution).validation.mdxalready documents the guarantee for validation predicates; nothing said so for flows. Added a callout toflows.mdx's "Expressions in flows" section stating the same guarantee now holds there, mirroringvalidation.mdx's language and cross-linkingformulas.mdx's existinghas()gotcha. (content/docs/releases/untouched, per the rule.)3. Two gates the derived list could not name — both triggered by what the change IS, not by which paths changed, so
dispatch-gates.mjshad no path signal for either:check:slot-lookup(The service-lookupanyrule missesgetService<any>(...)— 80 sites erase the slot contract, 3 of them inside the rule's own scope #4251 ratchet): the new real-engine integration test added 2kernel.getService(...) as anysites in a file already grandfathered for 11 pre-existing ones — the ratchet only tolerates the count it measured, not the file wholesale. Fixed by typing the two new sites viaIObjectQLEngine/IDataEngine(@objectstack/spec/contracts), matching the patternbulk-write-per-row-context.test.tsalready established in this same package, plus a small localTestObjectQLEngineintersection type for the two real, concrete-engine-only members (syncSchemas,registry.registerObject) the published contract doesn't declare.check:type-check-debt's TEST_DEBT re-measure: this package hides its own tests fromtsc(tsconfig.jsonexcludes**/*.test.ts), sopnpm --filter trigger-record-change typecheckpassing is NOT evidence about test-file correctness — a trap for any future dev in one of the 20 packages in this bucket. The re-measure runstscINCLUDING tests via a generated project and found 2 real errors past the frozen 9: theTS6059above (fixed by switching to the published export), and aTS2339from TypeScript inferring a narrow object-literal type through the canonical function's generic signature (fixed by an explicitRecord<string, unknown>cast at that one call site) — plus one MORE real error already latent in the original push (aTS2345in a newevalCeltest helper, typedunknowninstead ofRecord<string, unknown> | undefined), caught by replicating this exact measurement locally rather than trusting the package's ownpnpm typecheck. Also fixed, in my own new test only:{ logLevel: 'silent' }isn't a realObjectKernelConfigfield (logger: Partial<LoggerConfig>is) — an untyped excess property that measurably does nothing (createLogger(config.logger)never readslogLevel) and is already debt in this file's 6 OTHERits, which I left alone. Re-verified locally (replicating the TEST_DEBT project shape): 9 rawtscerrors, matching the frozen ledger exactly — net zero new debt.Measured, not assumed
@objectstack/formula'sExpressionEngine): the OLD raw merge shape faultsrecord.a != nullwithNo such key: a. Test:record-change-trigger.test.ts→'RED before the fix, reproduced directly: ...'.AutomationEngine.evaluateCondition, bindingrecord/previousunconditionally) and{record.x}interpolation share the sameAutomationContext.record/previous.record-change-integration.test.tsboots a real kernel (ObjectQL + automation + this trigger +@objectstack/driver-sqlon better-sqlite3:memory:) with arecord-before-updateflow whose start condition isrecord.tag != null && record.nickname == null—taguntouched by the update (folded fromprevious),nicknamedeclared but never set anywhere (materializednull).Out-of-scope, filed separately
#8482 —
RecordChangeDataEngine.getObjectConfigis declared but never implemented by the real ObjectQL engine, so the hydration schema-gate optimization is dead code in production (harmless — documented fallback, not a broken feature).findinglabel, unassigned, nopm:queue.Also out of scope: the objectui action
visible/disabledsparse-surface docs/lint share (ruling item 2), and #4811's null-guard gate widening (ruling item 3 — gated on both server-side seams; landing this PR makes it ripe, the widening itself is not done here).Tests
pnpm --filter '@objectstack/trigger-record-change^...' build— clean.pnpm --filter '@objectstack/trigger-record-change' typecheck— clean.pnpm --filter '@objectstack/trigger-record-change' test -- --maxWorkers=2— 6 test files, 75/75 passing.pnpm --filter '@objectstack/objectql' typecheck— clean.pnpm --filter '@objectstack/objectql' test— 197 test files, 3539/3539 passing (full suite; confirms the newcore.tsexport and boundary ratchet are undisturbed).pnpm --filter '@objectstack/lint' typecheck— clean (doc-only edit).check:nul-bytes,check:cross-package-test-inputs,check:durability-log-level,check:engine-double-contract,check:query-options-erasure,check:spec-parsed-alias,check:test-source-alias,check:type-source-resolution,check:type-check-coverage,check:slot-lookup,check-changeset-no-major.mjs,check-engine-split-ratio.mjs,check:changeset-gate-self-tests,check:objectui-changeset— all clean, re-verified after the follow-up commits.check:type-check-debt --re-measure, replicated locally against the exact TEST_DEBT project shape (scripts/check-type-check-coverage.mjs'sremeasureProject): 9/9, matching the frozen ledger — zero new debt.check:objectui-pin-freshis red, but pre-existing and unrelated — verified identically red on an unmodifiedmaincheckout (stale.objectui-shapin, Release process: prevent frontend (objectui) changesets being missed when the console pin lags #3340).Changesets
.changeset/flow-trigger-record-materialize.md—@objectstack/trigger-record-changepatch..changeset/objectql-export-materialize-declared-fields.md—@objectstack/objectqlpatch (additive export only).Generated by Claude Code