Skip to content

fix(seed): resolve relationship refs by natural key, not {externalId} objects (showcase + crm + compile-time guard)#1558

Merged
hotlong merged 2 commits into
mainfrom
claude/elegant-heisenberg-Se8vk
Jun 3, 2026
Merged

fix(seed): resolve relationship refs by natural key, not {externalId} objects (showcase + crm + compile-time guard)#1558
hotlong merged 2 commits into
mainfrom
claude/elegant-heisenberg-Se8vk

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Seed datasets wrote lookup / master_detail references as object literals, e.g. project: { externalId: 'Website Relaunch' }. The seed-loader resolves references from the plain natural-key string — it skips non-string values (packages/runtime/src/seed-loader.ts:273), so the wrapper object was never resolved and got handed straight to the SQL driver.

On a :memory: DB this was masked (always-empty at boot ⇒ INSERTs). Now that objectstack dev uses a persistent SQLite DB, restarting re-runs seeds as UPDATEs and better-sqlite3 throws:

SQLite3 can only bind numbers, strings, bigints, buffers, and null

— repeated Update operation failed { object: showcase_task ... project = {externalId:...} } noise, and those demo rows never update.

This was not an isolated showcase typo: examples/app-crm used the same object form throughout, and nothing stopped it — the seed record value type was unknown, so both forms compiled, and the failure only ever surfaced on a persistent DB.

Changes

1. Fix the example seed data (examples/app-showcase, examples/app-crm) — switch every relationship reference from { externalId: 'X' } to the plain natural-key string 'X':

  • showcase: project.account, task.project, category.parent, project_membership.team/project
  • crm: contact/opportunity/lead/activityaccount/contact/opportunity

2. Harden the framework so it can't recur (@objectstack/spec, @objectstack/runtime):

  • Compile-time: defineDataset now constrains lookup/master_detail field values to string | null (derived from each field's literal type). The object form is now a type error; all other fields stay unknown.
  • Runtime: SeedLoaderService now fails loudly with an actionable message and drops the value (instead of silently skipping it and letting it reach the driver) when a reference is an object — consistent behavior across all drivers. Covered by a new unit test.

Verification

  • Booted app-showcase and app-crm dev servers twice each against a persistent SQLite DB: 0 "can only bind", 0 "Update operation failed", 0 invalid-reference, 0 insert/update failures.
  • All FKs resolve to real record ids, cross-table consistent (e.g. task.project == the Website Relaunch id; crm contact.account == opportunity.account for Acme). All 12 crm opportunities seed; even the email-keyed activity.contact resolves.
  • Tests: @objectstack/spec (6628) ✅, @objectstack/runtime (342, incl. new guard test) ✅, example-showcase (20) ✅. Typecheck of app-showcase clean.

Note for reviewers

examples/app-crm has pre-existing typecheck failures unrelated to this PR (src/security, src/themes, src/webhooks import symbols like Security that aren't exported by the current @objectstack/spec — API drift). This PR does not touch those; the seed data file (src/data/index.ts) typechecks clean.

https://claude.ai/code/session_014XvTsiH3dPPqKgU8uaRjXR

…nalId} objects

The showcase seed datasets wrote lookup/master-detail fields as object
literals like `project: { externalId: 'Website Relaunch' }`. The seed
loader's reference resolution only processes plain-string natural keys
(it skips non-string values), so these objects passed through unresolved
and were handed straight to the driver.

On `:memory:` this was masked because every boot started empty and seeds
ran as INSERTs. With the persistent SQLite DB now used by `objectstack
dev`, restarts re-run seeds as UPDATEs, and better-sqlite3 throws
"SQLite3 can only bind numbers, strings, bigints, buffers, and null"
when it receives the raw `{ externalId: ... }` object — producing repeated
"Update operation failed" log noise and leaving those demo rows un-updated.

Switch every relationship/lookup reference (project.account, task.project,
category.parent, project_membership.team/project) to the plain natural-key
string the loader expects (e.g. `project: 'Website Relaunch'`). The loader
then resolves each to the referenced record's id via its externalId
mechanism on both the insert and update paths.

Verified by booting the showcase dev server twice against a persistent
dev.db: no "can only bind" / "Update operation failed" errors, and the
FK columns store resolved record ids matching across tables.
@vercel

vercel Bot commented Jun 3, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 3, 2026 7:31am

Request Review

…pile time; fix app-crm

Root cause (same class as the showcase fix): seed datasets wrote lookup /
master-detail references as `{ externalId: 'X' }` object literals, but the
loader resolves references from the plain natural-key string. The object was
silently skipped, passed through unresolved, and crashed the SQL driver on the
update path ("can only bind ...") — masked on an empty :memory: DB.

This wasn't an isolated showcase typo: app-crm used the same object form
everywhere, and nothing stopped it — the record value type was `unknown`, so
both forms compiled, and the failure only surfaced on a persistent DB. Hardening
so it can't recur:

- defineDataset: constrain lookup/master_detail field values to `string | null`
  at compile time (derived from each field's literal `type`). The object form is
  now a type error; all other fields stay `unknown`.
- SeedLoaderService: when a reference value is an object, fail loudly with an
  actionable message and drop the value instead of handing it to the driver —
  consistent across all drivers, no longer silently masked. Covered by a new
  unit test.
- app-crm seed data: switch every reference (contact/opportunity/lead/activity
  → account/contact/opportunity) to the natural-key string form.

Verified: ran app-crm dev twice against a persistent SQLite DB — 0 "can only
bind", 0 invalid-reference, 0 insert/update failures; all 12 opportunities seed
and every FK resolves to a real record id (incl. email-keyed activity.contact).
spec (6628), runtime (342), showcase (20) test suites pass.
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation size/s protocol:data tests tooling and removed size/s labels Jun 3, 2026
@os-zhuang os-zhuang changed the title fix(showcase): seed relationship fields with natural keys, not {externalId} objects fix(seed): resolve relationship refs by natural key, not {externalId} objects (showcase + crm + compile-time guard) Jun 3, 2026
@hotlong hotlong marked this pull request as ready for review June 3, 2026 07:38
@hotlong hotlong merged commit c33b6c5 into main Jun 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants