Skip to content

fix(seed-loader): multi-value lookup natural keys, and count the references they used to lose (#3911, #3932) - #3926

Merged
os-zhuang merged 7 commits into
mainfrom
claude/seed-loader-multi-lookup-keys-htjmyv
Jul 29, 2026
Merged

fix(seed-loader): multi-value lookup natural keys, and count the references they used to lose (#3911, #3932)#3926
os-zhuang merged 7 commits into
mainfrom
claude/seed-loader-multi-lookup-keys-htjmyv

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #3911. Fixes #3932.

Three pieces: the resolution bug, a showcase example, and the reporting gap that made the bug feel silent.


1. Multi-value lookups drop their natural-key array (#3911)

A multiple: true lookup / user field stores an array of ids, so its seed value is an array of natural keys (authors: ['Alice', 'Bob']). Reference resolution only ever accepted a single string:

if (typeof fieldValue === 'object') { /* report + delete record[ref.field] */ }

An array is an object in JS, so the value tripped that guard, emitted impossible-to-act-on advice ("Pass the target's name value as a plain string" — for a field that holds several references), and was dropped from the record. The row landed with the whole association missing. The loader had no notion that a reference field could be multi-value: buildDependencyGraph never carried multiple into ReferenceResolution.

Fix

  • ReferenceResolution gains an optional multiple flag, populated in buildDependencyGraph from the spec's shared isMultiValueField. lookup / user with multiple: true are recognized; master_detail (inherently single) correctly is not. Additive and defaulted-absent.
  • New resolveReferenceItem resolves one reference value — records seeded this load → DB probe → miss — and both passes share it, so an element deferred to pass 2 resolves by exactly the same rules. An array is just N calls to it.
  • The field lands as an array of target ids. A lone string is accepted as one-element shorthand, because that is the shape the field stores.
  • Deferral stays all-or-nothing per field. One unresolved element defers the whole authored array; pass 2 re-resolves every element and back-fills. A key that never materializes is a reported error naming that element, not the whole array stringified.
  • An array on a single-value field is still rejected, now with actionable advice. The field is dropped (never nulled), preserving the replay-safety invariant the file documents.

2. Showcase demonstrates it (#3911)

field_zoo covered every field type with a real seeded value except a multi-value reference — the one shape this bug was about. Adds f_lookups: Field.lookup('showcase_account', { multiple: true }), seeded from ['Northwind', 'Contoso']. That completes the specimen and doubles as a boot-time regression guard, which is what field_zoo exists for.

The multi-value user field (f_users) stays unseeded, with the reason now recorded on the object: sys_user rows come from sign-up, not seeds (see security/seed-approval-demo.ts — "users can't be seeded (they sign up)"), so on a fresh boot no human user exists for a natural key to resolve against. Authoring one would make the whole showcase seed load report success: false on every first boot. The multi-value reference mechanics the two fields share are covered by f_lookups.

3. A dropped reference field was invisible in every count (#3932)

The loader had two failure outcomes and counted one. A record it cannot write lands in errored. But an unusable reference value is removed from the record — never written as NULL, which would sever an existing link on upsert replay — and the row is written without it. Nothing counted that.

So a load that quietly severed N associations reported totalErrored: 0, and every count-driven surface read clean. The CLI boot banner — the one seed signal that survives os dev's boot-quiet window and the default warn level — printed showcase 42 rows, and the warn line said 0 dropped record(s): true, and useless. That reporting gap is why #3911 was reported as silent.

Adds SeedLoadResult.referencesDropped + SeedLoaderSummary.totalReferencesDropped, deliberately not folded into errored (the row was written, so that would break the inserted + updated + skipped reconciliation against total). Threaded through both seed-summary producers — AppPlugin's inline seed and the marketplace heal — to the banner:

⚠ Seeds:   showcase 42 ok / 3 lost links ⚠

Scoped out deliberately: a pass-2 deferred reference that never resolves already bumps errored and reports loudly, so it is not invisible. Re-classifying it would change existing behavior and the #2805 regression test for no gain here.


Verification

  • seed-loader-multi-value-reference.test.ts (new, 14 cases) — the issue's repro, DB-side resolution, internal-id passthrough mixed with natural keys, pass-2 back-fill via a circular graph, all-or-nothing deferral, the no-multiPass drop, single-value rejection, wrapper objects, replay idempotence, the graph flag, and the three seed-loader: a dropped reference FIELD is invisible in the summary — totalErrored counts dropped records only, so the boot banner reads clean #3932 counter cases (including that inserted + updated + skipped + errored == total still reconciles).
  • seed-multi-value-lookup-real-driver.integration.test.ts (new, 3 cases) — better-sqlite3 + the real ObjectQL engine. A multiple: true lookup lands in a JSON column, so the id array takes a serialize → TEXT → parse round-trip the in-memory mock never performs, and that round-trip is what the replay comparison reads back on the next boot. Proves the stored shape, that a second load skips all rows (no updated_at churn, no duplicate), and resolution against pre-existing rows.
  • format.seed-summary.test.ts (3 new cases) — the banner names lost links even when every row landed, is singular-aware, and stays quiet at zero.

Both new suites were confirmed to fail against pre-fix code. For the real-driver one that required rebuilding metadata-protocol's dist from the reverted source — the runtime tests resolve that package from dist, so a source-only revert proves nothing.

Also green: full spec (6823), metadata-protocol (84), runtime (754), cli (764), cloud-connection (64), and example-showcase (58) suites.

Docs

content/docs/data-modeling/seed-data.mdx gains a "Multi-Value Lookups (multiple: true)" section covering the array shape, the one-element shorthand, and the all-or-nothing deferral. Generated spec references regenerated. Two changesets included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah

…3911)

A `multiple: true` lookup / `user` field stores an ARRAY of ids, so its seed
value is an array of natural keys (`authors: ['Alice', 'Bob']`). Reference
resolution only ever accepted one string: the array tripped the "expected a
natural-key string but got an object. Pass the target's name value as a plain
string" guard — impossible advice for a field holding several references — and
was then dropped from the record. The row landed with the whole association
missing and only a warn in the log.

Resolution is now per element and shared by both passes
(`resolveReferenceItem`): in-load records first, then the database, then pass 2.
The field lands as an array of target ids, and a lone string is accepted as
one-element shorthand for the array shape the field stores.

Deferral stays all-or-nothing per field — a partially-written array is a corrupt
association, so pass 2 re-resolves the whole authored array — and a key that
never materializes is a reported load error naming that element, not a silent
drop.

An array on a genuinely single-value reference field is still rejected, now with
advice an author can act on: declare the field `multiple: true`, or pass one
natural key.

`ReferenceResolution` gains an optional `multiple` flag carrying the field's
array-ness into resolution (additive, defaulted-absent).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 29, 2026 11:31am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling size/l and removed documentation Improvements or additions to documentation protocol:data tests tooling labels Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/spec.

117 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, packages/runtime, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…multiple

Generated file — `check:docs` flagged it out of date after the new optional
`multiple` property landed on ReferenceResolutionSchema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Jul 29, 2026
…3911)

A `multiple: true` lookup lands in a JSON column, so the resolved id ARRAY
takes a serialize → SQLite TEXT → parse round-trip the in-memory mock never
performs — and that round-trip is exactly what the replay comparison reads back
on the next boot. The unit suite proves the resolution; only the real driver can
prove the stored shape, and that a seeded association is stable across restarts
instead of being rewritten or duplicated.

Three cases on better-sqlite3 + the real ObjectQL engine: the array round-trips
as an array of ids, a second load over the same database skips all three rows
(no `updated_at` churn, no duplicate book), and natural keys resolve against
authors that already existed in the database.

All three fail against pre-fix `metadata-protocol` (verified by rebuilding its
dist from the reverted source — a source-only revert proves nothing here, the
runtime tests resolve that package from `dist`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
claude added 2 commits July 29, 2026 10:48
`field_zoo` covered every field type with a real seeded value EXCEPT a
multi-value reference — the one shape #3911 was about. Adds
`f_lookups: Field.lookup('showcase_account', { multiple: true })`, seeded from
an array of natural keys (`['Northwind', 'Contoso']`) so each element resolves
independently to an account id.

That makes the specimen complete and doubles as a boot-time regression guard,
which is what field_zoo exists for: before the fix the array was rejected as a
non-string reference and the field vanished from the row, so the gallery
shipped silently missing one relational type.

The multi-value USER field (`f_users`) stays unseeded, now with the reason
recorded on the object: `sys_user` rows come from SIGN-UP, not seeds (see
security/seed-approval-demo.ts), so on a fresh boot no human user exists for a
natural key to resolve against. Authoring one would make the whole showcase
seed load report success: false on every first boot. The multi-value reference
mechanics the two fields share are covered by `f_lookups`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
…3932)

The loader had two failure outcomes and counted one. A record it cannot write
lands in `errored`. But an unusable reference VALUE (an object where a natural
key belongs, an array on a single-value field) is removed from the record —
never written as NULL, which would sever an existing link on upsert replay —
and the row is written without it. Nothing counted that.

So a load that quietly severed N associations reported `totalErrored: 0` and
every count-driven surface read clean. The boot banner — the one seed signal
that survives `os dev`'s boot-quiet window and the default warn level — printed
`showcase 42 rows`, and the warn line said "0 dropped record(s)": true, and
useless. That reporting gap is why #3911 was reported as silent.

Adds `SeedLoadResult.referencesDropped` + `SeedLoaderSummary
.totalReferencesDropped`, deliberately NOT folded into `errored`: the row WAS
written, so that would break the `inserted + updated + skipped` reconciliation
against `total`. Threaded through both seed-summary producers (AppPlugin's
inline seed, the marketplace heal) to the banner, which now names it:

  ⚠ Seeds:   showcase 42 ok / 3 lost links ⚠

and to the app-plugin warn line, which no longer reports "0 dropped record(s)"
over a load that lost associations.

Both counters are additive with a 0 default, so existing producers and
consumers of `SeedLoaderResult` are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
@github-actions github-actions Bot added size/xl and removed size/l labels Jul 29, 2026
@os-zhuang os-zhuang changed the title fix(seed-loader): resolve natural-key arrays for multi-value lookups (#3911) fix(seed-loader): multi-value lookup natural keys, and count the references they used to lose (#3911, #3932) Jul 29, 2026
claude added 2 commits July 29, 2026 11:15
CI caught the other half of #3911: the RUNTIME resolved a natural-key array
fine, but `defineSeed`'s record typing still declared every lookup as
`string | null`, so authoring the array the loader now wants was a compile
error (`Type 'string[]' is not assignable to type 'string'`) — the showcase
seed added in the previous commit is exactly that case.

`SeedFieldValue` widens a `multiple: true` lookup to `string | string[] | null`.
A lone string stays legal there (the loader accepts it as one-element shorthand
for the array shape the field stores); `master_detail` is inherently single and
is not widened; an array on a single-value lookup and a wrapper object inside a
multi array both remain compile errors — the guard that comment promises is
intact, verified in both directions.

Reaching that required `Field.lookup` to stop widening its config: with
`config: FieldInput` the literal `multiple: true` collapses to `boolean` before
it ever reaches the seed type. It is now generic over a `const` type parameter,
with the return type intersected with `FieldInput` so the optional surface is
unchanged — narrowing to just the passed keys broke consumers that read
`.options` off a field union (app-todo). Type-level only; the returned object is
identical at runtime.

Repo-wide `pnpm -r typecheck` is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
The f_lookups field added to the field zoo declared an English label with
no zh-CN counterpart, tripping the i18n coverage ratchet (456 -> 457
untranslated declared strings). Translate it so the new string does not
widen the baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
@os-zhuang
os-zhuang merged commit 9f40933 into main Jul 29, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/seed-loader-multi-lookup-keys-htjmyv branch July 29, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

2 participants