From 07971c03c8c1a46b74467e1224d6eec30e0485e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:59:06 +0000 Subject: [PATCH 1/7] fix(seed-loader): resolve natural-key arrays for multi-value lookups (#3911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- .changeset/seed-loader-multi-value-lookup.md | 31 ++ content/docs/data-modeling/seed-data.mdx | 30 ++ .../seed-loader-multi-value-reference.test.ts | 325 ++++++++++++++++++ packages/metadata-protocol/src/seed-loader.ts | 264 ++++++++++---- packages/spec/authorable-surface.json | 1 + packages/spec/src/data/seed-loader.zod.ts | 8 + 6 files changed, 585 insertions(+), 74 deletions(-) create mode 100644 .changeset/seed-loader-multi-value-lookup.md create mode 100644 packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts diff --git a/.changeset/seed-loader-multi-value-lookup.md b/.changeset/seed-loader-multi-value-lookup.md new file mode 100644 index 0000000000..cc74a62088 --- /dev/null +++ b/.changeset/seed-loader-multi-value-lookup.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +--- + +fix(seed-loader): resolve natural-key ARRAYS for multi-value lookups + +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: 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 that holds +several references — and was then DROPPED from the record. The row landed with +the whole association missing and only a warn in the log +([#3911](https://github.com/objectstack-ai/objectstack/issues/3911)). + +Every element now resolves independently (in-load records first, then the +database, then pass 2), and the field lands as an array of target ids. A lone +string is accepted as one-element shorthand for the array shape the field +stores. Deferral is all-or-nothing per field — a partially-resolved 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 passed to 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` (`@objectstack/spec/data`) gains an optional `multiple` +flag carrying the field's array-ness into resolution; it is additive and +defaulted-absent, so existing dependency graphs are unaffected. diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 8c05bac394..41b00eb40c 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -238,6 +238,36 @@ const contactsSeed = defineSeed(Contact, { export const SeedData = [accountsSeed, contactsSeed]; ``` +### Multi-Value Lookups (`multiple: true`) + +A lookup declared `multiple: true` stores an **array** of related record ids, so its +seed value is an **array of natural keys**. Each element resolves independently. + +```typescript +const Book = ObjectSchema.create({ + name: 'book', + fields: { + name: Field.text({ label: 'Title', required: true }), + authors: Field.lookup('author', { label: 'Authors', multiple: true }), + }, +}); + +const booksSeed = defineSeed(Book, { + externalId: 'name', + records: [ + { name: 'Refactoring', authors: ['Alice', 'Bob'] }, // one natural key per author + ], +}); +``` + +A lone string is accepted as one-element shorthand (`authors: 'Alice'` stores +`[]`). Resolution is **all-or-nothing per field**: if any element is still +missing, the whole array is deferred to pass 2 rather than written half-resolved, and +a key that never materializes is reported as a load error naming that element. + +Passing an array to a **single-value** lookup is rejected with a load error — declare +the field `multiple: true`, or pass one natural key. + ### Composite `externalId` — Join / Junction Tables A join (junction) table linking two objects many-to-many has **no single-field diff --git a/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts b/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts new file mode 100644 index 0000000000..38812ee5a4 --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts @@ -0,0 +1,325 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; + +/** + * Multi-value reference resolution (`Field.lookup(..., { multiple: true })`). + * + * The seed value for a multi-value lookup is an ARRAY of natural keys — + * `authors: ['Alice', 'Bob']`. Reference resolution used to reject anything + * non-string outright ("expected a natural-key string but got an object. + * Pass the target's name value as a plain string"), which for a `multiple` + * field is impossible advice: one string cannot express several associations. + * The array was then DROPPED from the record, so the row landed with the whole + * relationship missing and only a warn in the log (framework#3911). + * + * These tests pin the fix: every element resolves independently, the field + * lands as an array of target ids, deferral is all-or-nothing per field, and a + * genuinely single-value field still rejects an array — loudly and with advice + * an author can act on. + */ + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +/** Faithful in-memory engine (where-filtering find, array insert) — same shape + * the other seed-loader suites use, plus a `getSchema` registry. */ +function createEngine(schemas: Record) { + const store: Record = {}; + let idCounter = 0; + + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => r[k] === v), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records.map((r) => ({ ...r })); + }), + findOne: vi.fn(async (objectName: string, query?: any) => { + const rows = await (engine.find as any)(objectName, { ...query, limit: 1 }); + return rows[0] ?? null; + }), + insert: vi.fn(async (objectName: string, data: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d })); + store[objectName].push(...records); + return records; + } + const record = { id: `gen-${++idCounter}`, ...data }; + store[objectName].push(record); + return record; + }), + update: vi.fn(async (objectName: string, data: any) => { + const records = store[objectName] || []; + const idx = records.findIndex((r) => r.id === data.id); + if (idx >= 0) { + records[idx] = { ...records[idx], ...data }; + return records[idx]; + } + return data; + }), + delete: vi.fn(async () => ({ deleted: 1 })), + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), + aggregate: vi.fn(async () => []), + getSchema: vi.fn((objectName: string) => schemas[objectName]), + } as unknown as IDataEngine & { getSchema: ReturnType }; + + return { engine, store }; +} + +function createEmptyMetadata(): IMetadataService { + return { + getObject: vi.fn(async () => undefined), + listObjects: vi.fn(async () => []), + register: vi.fn(async () => {}), + get: vi.fn(async () => undefined), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +/** The issue's minimal repro: a book with several authors. `reviewer` is the + * single-value control that must keep rejecting an array. */ +const SCHEMAS: Record = { + author: { + name: 'author', + fields: { name: { type: 'text', required: true } }, + }, + book: { + name: 'book', + fields: { + name: { type: 'text', required: true }, + authors: { type: 'lookup', reference: 'author', multiple: true }, + reviewer: { type: 'lookup', reference: 'author' }, + }, + }, +}; + +const CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, +} as any; + +const AUTHOR_SEED = { + object: 'author', + externalId: 'name', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [{ name: 'Alice' }, { name: 'Bob' }], +}; + +const bookSeed = (records: any[]) => ({ + object: 'book', + externalId: 'name', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records, +}); + +describe('seed reference resolution — multi-value lookup (multiple: true)', () => { + it('resolves every natural key in the array to a target id', async () => { + const { engine, store } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(result.summary.totalErrored).toBe(0); + + const alice = store.author.find((r) => r.name === 'Alice')!; + const bob = store.author.find((r) => r.name === 'Bob')!; + const book = store.book.find((r) => r.name === 'Refactoring')!; + + // The bug: `authors` was deleted from the record entirely. + expect(book.authors).toEqual([alice.id, bob.id]); + // Order is the authored order — a stored array is ordered data. + expect(book.authors[0]).toBe(alice.id); + }); + + it('resolves against rows that already exist in the database', async () => { + const { engine, store } = createEngine(SCHEMAS); + store.author = [ + { id: 'author-existing-1', name: 'Alice' }, + { id: 'author-existing-2', name: 'Bob' }, + ]; + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(store.book[0].authors).toEqual(['author-existing-1', 'author-existing-2']); + expect(result.summary.totalReferencesResolved).toBe(2); + }); + + it('normalizes a lone natural key to the array shape the field stores', async () => { + const { engine, store } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: 'Alice' }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + const alice = store.author.find((r) => r.name === 'Alice')!; + expect(store.book[0].authors).toEqual([alice.id]); + }); + + it('passes internal ids through untouched, mixed with natural keys', async () => { + const { engine, store } = createEngine(SCHEMAS); + const existingId = '11111111-2222-4333-8444-555555555555'; // UUID → looksLikeInternalId + store.author = [{ id: existingId, name: 'Carol' }]; + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: [existingId, 'Bob'] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + const bob = store.author.find((r) => r.name === 'Bob')!; + expect(store.book[0].authors).toEqual([existingId, bob.id]); + }); + + it('back-fills the whole array in pass 2 when the targets load later (circular graph)', async () => { + // `author.favorite_book → book` and `book.authors → author` form a cycle, + // so `book` loads before its authors exist and defers the WHOLE array. + const cyclic = { + author: { + name: 'author', + fields: { + name: { type: 'text', required: true }, + favorite_book: { type: 'lookup', reference: 'book' }, + }, + }, + book: SCHEMAS.book, + }; + const { engine, store } = createEngine(cyclic); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [ + bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }]), + { ...AUTHOR_SEED, records: [{ name: 'Alice', favorite_book: 'Refactoring' }, { name: 'Bob' }] }, + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(result.summary.totalErrored).toBe(0); + + const alice = store.author.find((r) => r.name === 'Alice')!; + const bob = store.author.find((r) => r.name === 'Bob')!; + const book = store.book.find((r) => r.name === 'Refactoring')!; + + expect(book.authors).toEqual([alice.id, bob.id]); + expect(alice.favorite_book).toBe(book.id); + }); + + it('defers the array as a WHOLE — never writes a half-resolved association', async () => { + const { engine, store } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + // 'Nobody' never materializes, so the field stays deferred and then errors. + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Nobody'] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(false); + // The partial `[alice.id]` must NOT have been written. + expect(store.book[0].authors).toBeUndefined(); + // The error names the element at fault, not the whole array. + expect(result.errors.some((e) => e.message.includes("'Nobody'"))).toBe(true); + }); + + it('drops the record (loudly) when an element cannot resolve and no pass 2 will run', async () => { + const { engine, store } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Nobody'] }])] as any, + config: { ...CONFIG, multiPass: false }, + }); + + expect(result.success).toBe(false); + expect(store.book).toBeUndefined(); + expect(result.errors.some((e) => e.message.includes('Cannot resolve reference: book.authors'))).toBe(true); + }); + + it('rejects an array on a SINGLE-value reference field with actionable advice', async () => { + const { engine, store } = createEngine(SCHEMAS); + const logger = createLogger(); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), logger).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', reviewer: ['Alice', 'Bob'] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(false); + const message = result.errors.find((e) => e.field === 'reviewer')!.message; + expect(message).toContain('but got an array'); + expect(message).toContain('multiple: true'); + // The unwritable value never reaches the driver; the record still lands. + expect(store.book[0].reviewer).toBeUndefined(); + expect(store.book[0].name).toBe('Refactoring'); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('still rejects a wrapper object inside a multi-value array', async () => { + const { engine } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: [{ externalId: 'Alice' }] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(false); + const message = result.errors.find((e) => e.field === 'authors')!.message; + expect(message).toContain('Pass the natural key directly: authors: "Alice"'); + }); + + it('replays idempotently — a resolved array is not rewritten on the second load', async () => { + const { engine, store } = createEngine(SCHEMAS); + const seeds = [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any; + const loader = new SeedLoaderService(engine, createEmptyMetadata(), createLogger()); + + await loader.load({ seeds, config: CONFIG }); + const first = store.book[0].authors; + + const replay = await loader.load({ seeds, config: CONFIG }); + + expect(replay.success).toBe(true); + // Replay compares the resolved array against the stored one — no churn. + expect(replay.summary.totalUpdated).toBe(0); + expect(replay.summary.totalSkipped).toBe(3); + expect(store.book).toHaveLength(1); + expect(store.book[0].authors).toEqual(first); + }); + + it('reports the array shape in the dependency graph', async () => { + const { engine } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice'] }])] as any, + config: CONFIG, + }); + + const bookNode = result.dependencyGraph.nodes.find((n) => n.object === 'book')!; + expect(bookNode.references.find((r) => r.field === 'authors')!.multiple).toBe(true); + expect(bookNode.references.find((r) => r.field === 'reviewer')!.multiple).toBeUndefined(); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index dcdfb765f2..462bd29ce1 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -13,7 +13,7 @@ import type { SeedLoadResult, Seed, } from '@objectstack/spec/data'; -import { SeedLoaderConfigSchema } from '@objectstack/spec/data'; +import { SeedLoaderConfigSchema, isMultiValueField } from '@objectstack/spec/data'; import { resolveSeedRecord } from '@objectstack/formula'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from '@objectstack/core'; @@ -178,6 +178,11 @@ export class SeedLoaderService implements ISeedLoaderService { targetObject, targetField: DEFAULT_EXTERNAL_ID_FIELD, fieldType: fieldDef.type as 'lookup' | 'master_detail' | 'user', + // `multiple: true` (lookup/user) stores an ARRAY of ids, so the + // seed value is an array of natural keys — carried here so + // resolution knows to map over it instead of rejecting the array + // as a non-string reference value (framework#3911). + multiple: isMultiValueField(fieldDef as { type: string; multiple?: boolean }) || undefined, }); } } @@ -472,32 +477,83 @@ export class SeedLoaderService implements ISeedLoaderService { const fieldValue = record[ref.field]; if (fieldValue === undefined || fieldValue === null) continue; - // LOUD FAILURE: a reference must be a natural-key string (or an - // internal id). An object value — e.g. the wrapper `{ externalId: 'X' }` - // — never resolves: it would otherwise fall through unresolved and reach - // the driver as a non-bindable value ("SQLite3 can only bind ..."). This - // used to be silently skipped (and only crashed on a persistent DB's - // update path), so catch it here and report the actionable fix instead. - if (typeof fieldValue === 'object') { - const wrapped = (fieldValue as Record).externalId; - const hint = - wrapped !== undefined - ? ` Pass the natural key directly: ${ref.field}: ${JSON.stringify(wrapped)}.` - : ` Pass the target's ${ref.targetField} value as a plain string.`; + const pushError = (message: string, attemptedValue: unknown): ReferenceResolutionError => { const error: ReferenceResolutionError = { sourceObject: objectName, field: ref.field, targetObject: ref.targetObject, targetField: ref.targetField, - attemptedValue: fieldValue, + attemptedValue, recordIndex: i, - message: - `Invalid reference for ${objectName}.${ref.field}: expected a ` + - `${ref.targetObject}.${ref.targetField} natural-key string but got an object.${hint}`, + message, }; errors.push(error); allErrors.push(error); + return error; + }; + + // LOUD FAILURE: an ARRAY of natural keys is only writable by a field + // that stores an array — `Field.lookup(..., { multiple: true })` (or a + // multi `user` field). On a single-value field it can never resolve, so + // report the actionable fix instead of letting it reach the driver. + if (Array.isArray(fieldValue) && !ref.multiple) { + const error = pushError( + `Invalid reference for ${objectName}.${ref.field}: expected a single ` + + `${ref.targetObject}.${ref.targetField} natural-key string but got an array. ` + + `Declare the field as \`multiple: true\` to store several references, ` + + `or pass one natural key.`, + fieldValue, + ); this.logger.warn(`[SeedLoader] ${error.message}`, { recordIndex: i }); + // Drop the unwritable value so it never reaches the driver. Removing + // the key (not writing null) matters on the upsert UPDATE path — see + // the deferred-reference note below. + delete record[ref.field]; + continue; + } + + // A `multiple: true` field's stored shape IS an array, so a lone + // natural key is one-element shorthand for it; a single-value field + // keeps its scalar shape. Either way every element resolves the same. + const items = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; + const resolvedItems: unknown[] = []; + let invalidItem = false; + let unresolvedItem: unknown; + let sawUnresolved = false; + + for (const item of items) { + // A null/undefined hole carries no reference — drop it rather than + // writing it into the stored array. + if (item === undefined || item === null) continue; + + const outcome = await this.resolveReferenceItem(ref, item, insertedRecords, config); + if (outcome.status === 'invalid') { + // LOUD FAILURE: a reference must be a natural-key string (or an + // internal id). An object value — e.g. the wrapper + // `{ externalId: 'X' }` — never resolves: it would otherwise fall + // through unresolved and reach the driver as a non-bindable value + // ("SQLite3 can only bind ..."). This used to be silently skipped + // (and only crashed on a persistent DB's update path), so catch it + // here and report the actionable fix instead. + const error = pushError( + `Invalid reference for ${objectName}.${ref.field}: expected a ` + + `${ref.targetObject}.${ref.targetField} natural-key string but got an object.${outcome.hint}`, + item, + ); + this.logger.warn(`[SeedLoader] ${error.message}`, { recordIndex: i }); + invalidItem = true; + break; + } + if (outcome.status === 'unresolved') { + unresolvedItem = item; + sawUnresolved = true; + break; + } + if (outcome.status === 'resolved') referencesResolved++; + resolvedItems.push(outcome.value); + } + + if (invalidItem) { // Drop the unresolvable value so it never reaches the driver. // Removing the key (not writing null) matters on the upsert UPDATE // path: an explicit null would overwrite the existing row's valid @@ -506,22 +562,14 @@ export class SeedLoaderService implements ISeedLoaderService { continue; } - // Skip if value looks like an internal ID (not a natural key) - if (typeof fieldValue !== 'string' || this.looksLikeInternalId(fieldValue)) continue; - - // Try to resolve via already-inserted records - const targetMap = insertedRecords.get(ref.targetObject); - const resolvedId = targetMap?.get(String(fieldValue)); - - if (resolvedId) { - record[ref.field] = resolvedId; - referencesResolved++; - } else if (!config.dryRun) { - // Try to resolve from existing data in the database - const dbId = await this.resolveFromDatabase(ref.targetObject, ref.targetField, fieldValue, config.organizationId); - if (dbId) { - record[ref.field] = dbId; - referencesResolved++; + if (sawUnresolved) { + if (config.dryRun) { + // Dry-run: report the miss but leave the authored value untouched. + pushError( + `[dry-run] Reference may not resolve: ${objectName}.${ref.field} = ` + + `'${String(unresolvedItem)}' → ${ref.targetObject}.${ref.targetField}`, + unresolvedItem, + ); } else if (config.multiPass) { // Defer to pass 2. REMOVE the field rather than writing null: // on insert a missing column lands NULL anyway (placeholder until @@ -530,6 +578,10 @@ export class SeedLoaderService implements ISeedLoaderService { // reference — every dev-server restart severed one link per // replayed record (NOT NULL columns turned this into a loud // constraint error; nullable ones silently lost the association). + // + // A multi-value field defers as a WHOLE (the original authored + // array): a partially-written array is a corrupt association, and + // pass 2 re-resolves every element from the same authored keys. delete record[ref.field]; deferredUpdates.push({ objectName, @@ -538,6 +590,7 @@ export class SeedLoaderService implements ISeedLoaderService { targetObject: ref.targetObject, targetField: ref.targetField, attemptedValue: fieldValue, + multiple: ref.multiple === true, recordIndex: i, }); referencesDeferred++; @@ -546,36 +599,20 @@ export class SeedLoaderService implements ISeedLoaderService { // (LOUD: counted + reported). Writing it anyway would either // carry the raw natural-key string into the FK column or, on // update, corrupt the existing row. - const error: ReferenceResolutionError = { - sourceObject: objectName, - field: ref.field, - targetObject: ref.targetObject, - targetField: ref.targetField, - attemptedValue: fieldValue, - recordIndex: i, - message: `Cannot resolve reference: ${objectName}.${ref.field} = '${fieldValue}' → ${ref.targetObject}.${ref.targetField} not found`, - }; - errors.push(error); - allErrors.push(error); + pushError( + `Cannot resolve reference: ${objectName}.${ref.field} = '${String(unresolvedItem)}' → ` + + `${ref.targetObject}.${ref.targetField} not found`, + unresolvedItem, + ); unresolvedRefError = true; } - } else { - // Dry-run: attempt resolution, report error if not found - const targetMap2 = insertedRecords.get(ref.targetObject); - if (!targetMap2?.has(String(fieldValue))) { - const error: ReferenceResolutionError = { - sourceObject: objectName, - field: ref.field, - targetObject: ref.targetObject, - targetField: ref.targetField, - attemptedValue: fieldValue, - recordIndex: i, - message: `[dry-run] Reference may not resolve: ${objectName}.${ref.field} = '${fieldValue}' → ${ref.targetObject}.${ref.targetField}`, - }; - errors.push(error); - allErrors.push(error); - } + continue; } + + // Every element resolved (or was already an internal id). A + // `multiple: true` field always lands an ARRAY — including when the + // seed authored a lone key — because that is its stored shape. + record[ref.field] = ref.multiple ? resolvedItems : resolvedItems[0]; } // A definitively unresolvable reference (no pass 2 to fix it) drops the @@ -717,6 +754,57 @@ export class SeedLoaderService implements ISeedLoaderService { return undefined; } + /** + * Resolve ONE reference value — the whole value of a single-value field, or + * one element of a `multiple: true` field's array — against the records + * seeded so far and then the database. + * + * Splitting this out is what lets a multi-value lookup work at all: the + * authored `authors: ['Alice', 'Bob']` is N independent natural keys, not one + * unresolvable "object" (framework#3911). Both passes share it so an element + * deferred to pass 2 resolves by exactly the same rules. + */ + private async resolveReferenceItem( + ref: { field: string; targetObject: string; targetField: string }, + value: unknown, + insertedRecords: Map>, + config: { dryRun?: boolean; organizationId?: string }, + ): Promise< + | { status: 'resolved'; value: string } + | { status: 'kept'; value: unknown } + | { status: 'invalid'; hint: string } + | { status: 'unresolved' } + > { + if (typeof value === 'object') { + const wrapped = (value as Record).externalId; + return { + status: 'invalid', + hint: + wrapped !== undefined + ? ` Pass the natural key directly: ${ref.field}: ${JSON.stringify(wrapped)}.` + : ` Pass the target's ${ref.targetField} value as a plain string.`, + }; + } + + // Not a natural key (an internal id, or a non-string the engine will + // reject on its own terms) — keep it verbatim. + if (typeof value !== 'string' || this.looksLikeInternalId(value)) { + return { status: 'kept', value }; + } + + // Records seeded during THIS load first, then existing rows in the DB. + const fromThisLoad = insertedRecords.get(ref.targetObject)?.get(value); + if (fromThisLoad) return { status: 'resolved', value: fromThisLoad }; + + // Dry-run never probes the database — an in-memory miss is the verdict. + if (config.dryRun) return { status: 'unresolved' }; + + const fromDatabase = await this.resolveFromDatabase( + ref.targetObject, ref.targetField, value, config.organizationId, + ); + return fromDatabase ? { status: 'resolved', value: fromDatabase } : { status: 'unresolved' }; + } + private async resolveFromDatabase( targetObject: string, targetField: string, @@ -767,18 +855,36 @@ export class SeedLoaderService implements ISeedLoaderService { organizationId?: string, ): Promise { for (const deferred of deferredUpdates) { - // Try to resolve from inserted records - const targetMap = insertedRecords.get(deferred.targetObject); - let resolvedId = targetMap?.get(String(deferred.attemptedValue)); - - // Try database fallback - if (!resolvedId) { - resolvedId = (await this.resolveFromDatabase( - deferred.targetObject, deferred.targetField, deferred.attemptedValue, organizationId - )) ?? undefined; + // A multi-value field deferred its WHOLE authored array (see pass 1), so + // re-resolve every element here; a single-value field has exactly one. + const items = Array.isArray(deferred.attemptedValue) + ? deferred.attemptedValue + : [deferred.attemptedValue]; + const resolvedItems: unknown[] = []; + let missingItem: unknown; + let stillUnresolved = false; + + for (const item of items) { + if (item === undefined || item === null) continue; + const outcome = await this.resolveReferenceItem( + deferred, item, insertedRecords, { organizationId }, + ); + if (outcome.status === 'resolved' || outcome.status === 'kept') { + resolvedItems.push(outcome.value); + continue; + } + // 'invalid' can't reach pass 2 (pass 1 drops it), but treat it as a + // miss rather than writing an unresolvable value. + missingItem = item; + stillUnresolved = true; + break; } - if (resolvedId) { + // A multi-value field writes the array it re-resolved; a single-value one + // writes its lone id. An empty result is never a resolution. + const resolvedValue: unknown = deferred.multiple ? resolvedItems : resolvedItems[0]; + + if (!stillUnresolved && resolvedItems.length > 0) { // Find the record and update the reference const objectRecordMap = insertedRecords.get(deferred.objectName); const recordId = objectRecordMap?.get(deferred.recordExternalId); @@ -793,7 +899,7 @@ export class SeedLoaderService implements ISeedLoaderService { // self-trigger vector SEED_OPTIONS exists to prevent (#3760). await withTransientRetry(() => this.engine.update(deferred.objectName, { id: recordId, - [deferred.field]: resolvedId, + [deferred.field]: resolvedValue, }, SeedLoaderService.SEED_OPTIONS as any)); // Update result stats @@ -816,13 +922,15 @@ export class SeedLoaderService implements ISeedLoaderService { error: err?.message, }); this.recordDeferredError(deferred, allResults, allErrors, - `Failed to write deferred reference: ${deferred.objectName}.${deferred.field} = '${deferred.attemptedValue}' → ${deferred.targetObject}.${deferred.targetField}: ${err?.message ?? String(err)}`); + `Failed to write deferred reference: ${deferred.objectName}.${deferred.field} = '${this.formatAttempted(deferred.attemptedValue)}' → ${deferred.targetObject}.${deferred.targetField}: ${err?.message ?? String(err)}`); } } } else { - // Still unresolved after pass 2 — the target never materialized. + // Still unresolved after pass 2 — the target never materialized. Name + // the element that missed: on a multi-value field only one of several + // natural keys is usually at fault. this.recordDeferredError(deferred, allResults, allErrors, - `Deferred reference unresolved after pass 2: ${deferred.objectName}.${deferred.field} = '${deferred.attemptedValue}' → ${deferred.targetObject}.${deferred.targetField} not found`); + `Deferred reference unresolved after pass 2: ${deferred.objectName}.${deferred.field} = '${this.formatAttempted(stillUnresolved ? missingItem : deferred.attemptedValue)}' → ${deferred.targetObject}.${deferred.targetField} not found`); } } } @@ -1324,6 +1432,11 @@ export class SeedLoaderService implements ISeedLoaderService { return String(record[externalId] ?? ''); } + /** Readable rendering of an attempted reference value (`['a','b']` for a multi-value field). */ + private formatAttempted(value: unknown): string { + return Array.isArray(value) ? JSON.stringify(value) : String(value); + } + /** Human-readable label for an externalId (single field, or `a+b` for a composite). */ private externalIdLabel(externalId: string | string[]): string { return Array.isArray(externalId) ? externalId.join('+') : externalId; @@ -1394,6 +1507,9 @@ interface DeferredUpdate { field: string; targetObject: string; targetField: string; + /** Authored value — one natural key, or the WHOLE array for a `multiple: true` field. */ attemptedValue: unknown; + /** Source field stores an array of references, so pass 2 back-fills an array. */ + multiple?: boolean; recordIndex: number; } diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index cb696817e5..c812723be9 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3692,6 +3692,7 @@ "data/QueryFilter:where", "data/ReferenceResolution:field", "data/ReferenceResolution:fieldType", + "data/ReferenceResolution:multiple", "data/ReferenceResolution:targetField", "data/ReferenceResolution:targetObject", "data/ReferenceResolutionError:attemptedValue", diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts index e135582991..2cf7480a95 100644 --- a/packages/spec/src/data/seed-loader.zod.ts +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -51,6 +51,14 @@ export const ReferenceResolutionSchema = lazySchema(() => z.object({ /** The field type that triggered this resolution (lookup, master_detail, or user) */ fieldType: z.enum(['lookup', 'master_detail', 'user']).describe('Relationship field type'), + + /** + * Mirrors `FieldSchema.multiple`: the field stores an ARRAY of references, so + * its seed value is an array of natural keys and every element resolves + * independently to a target id. A single-value field (the default) carries + * exactly one natural key. + */ + multiple: z.boolean().optional().describe('Field stores an array of references (multiple: true)'), }).describe('Describes how a field reference is resolved during seed loading')); export type ReferenceResolution = z.infer; From 6ccc41df0707f3f098fe0babe3ba2acc4e23e071 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:31:26 +0000 Subject: [PATCH 2/7] docs(spec): regenerate seed-loader reference for ReferenceResolution.multiple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated file — `check:docs` flagged it out of date after the new optional `multiple` property landed on ReferenceResolutionSchema. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- content/docs/references/data/seed-loader.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx index e1040fa5aa..af2c5f8ead 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -61,7 +61,7 @@ Complete object dependency graph for seed data loading | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **nodes** | `{ object: string; dependsOn: string[]; references: { field: string; targetObject: string; targetField: string; fieldType: Enum<'lookup' \| 'master_detail' \| 'user'> }[] }[]` | ✅ | All objects in the dependency graph | +| **nodes** | `{ object: string; dependsOn: string[]; references: { field: string; targetObject: string; targetField: string; fieldType: Enum<'lookup' \| 'master_detail' \| 'user'>; … }[] }[]` | ✅ | All objects in the dependency graph | | **insertOrder** | `string[]` | ✅ | Topologically sorted insert order | | **circularDependencies** | `string[][]` | ✅ | Circular dependency chains (e.g., [["a", "b", "a"]]) | @@ -78,7 +78,7 @@ Object node in the seed data dependency graph | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (snake_case) | | **dependsOn** | `string[]` | ✅ | Objects this object depends on | -| **references** | `{ field: string; targetObject: string; targetField: string; fieldType: Enum<'lookup' \| 'master_detail' \| 'user'> }[]` | ✅ | Field-level reference details | +| **references** | `{ field: string; targetObject: string; targetField: string; fieldType: Enum<'lookup' \| 'master_detail' \| 'user'>; … }[]` | ✅ | Field-level reference details | --- @@ -95,6 +95,7 @@ Describes how a field reference is resolved during seed loading | **targetObject** | `string` | ✅ | Target object name (snake_case) | | **targetField** | `string` | ✅ | Field on target object used for matching | | **fieldType** | `Enum<'lookup' \| 'master_detail' \| 'user'>` | ✅ | Relationship field type | +| **multiple** | `boolean` | optional | Field stores an array of references (multiple: true) | --- @@ -199,7 +200,7 @@ Complete seed loader result | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Overall success status | | **dryRun** | `boolean` | ✅ | Whether this was a dry-run | -| **dependencyGraph** | `{ nodes: { object: string; dependsOn: string[]; references: { field: string; targetObject: string; targetField: string; fieldType: Enum<'lookup' \| 'master_detail' \| 'user'> }[] }[]; insertOrder: string[]; circularDependencies: string[][] }` | ✅ | Object dependency graph | +| **dependencyGraph** | `{ nodes: { object: string; dependsOn: string[]; references: { field: string; targetObject: string; targetField: string; fieldType: Enum<'lookup' \| 'master_detail' \| 'user'>; … }[] }[]; insertOrder: string[]; circularDependencies: string[][] }` | ✅ | Object dependency graph | | **results** | `{ object: string; mode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; inserted: integer; updated: integer; … }[]` | ✅ | Per-object load results | | **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | ✅ | All reference resolution errors | | **summary** | `{ objectsProcessed: integer; totalRecords: integer; totalInserted: integer; totalUpdated: integer; … }` | ✅ | Summary statistics | From be62b504730bd68b0a3c2a5a1493f32c38ebb6f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:38:33 +0000 Subject: [PATCH 3/7] test(seed-loader): pin multi-value lookup seeds on a REAL SqlDriver (#3911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- ...lue-lookup-real-driver.integration.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts diff --git a/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts b/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts new file mode 100644 index 0000000000..a5b45b249d --- /dev/null +++ b/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Real-driver regression for multi-value lookup seeds (framework#3911). +// +// A `multiple: true` lookup is stored in a JSON column, so the resolved id +// ARRAY takes a serialize → SQLite TEXT → parse round-trip that an in-memory +// mock driver never performs. That round-trip is exactly what the loader's +// replay comparison reads back on the next boot, so it decides whether a +// seeded association is stable or rewritten (or duplicated) on every restart. +// The unit suite in metadata-protocol proves the resolution; only the real +// SqlDriver can prove the stored shape and the replay. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SeedLoaderService } from '@objectstack/metadata-protocol'; +import { SqlDriver } from '@objectstack/driver-sql'; + +const AUTHOR = { + name: 'author', + fields: { name: { type: 'text', required: true } }, +}; +const BOOK = { + name: 'book', + fields: { + name: { type: 'text', required: true }, + authors: { type: 'lookup', reference: 'author', multiple: true }, + reviewer: { type: 'lookup', reference: 'author' }, + }, +}; + +const SEED_CONFIG = { + dryRun: false, haltOnError: false, multiPass: true, + defaultMode: 'upsert', batchSize: 1000, transaction: false, +} as any; +const logger = { info() {}, warn() {}, error() {}, debug() {} }; + +function metadataFor(objects: any[]) { + const byName = new Map(objects.map((o) => [o.name, o])); + return { + getObject: async (name: string) => byName.get(name), + listObjects: async () => objects, + register: async () => {}, get: async (_t: string, n: string) => byName.get(n), + list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [], + } as any; +} + +/** The issue's minimal repro, verbatim. */ +const SEEDS = [ + { + object: 'author', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Alice' }, { name: 'Bob' }], + }, + { + object: 'book', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Refactoring', authors: ['Alice', 'Bob'] }], + }, +]; + +describe('multi-value lookup seeds on a REAL SqlDriver (framework#3911)', () => { + let dir: string | null = null; + let engine: ObjectQL | null = null; + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = null; + if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + }); + + async function boot(objects: any[]) { + dir = mkdtempSync(join(tmpdir(), 'os-seed-multi-real-')); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + await driver.initObjects(objects); // real tables, real JSON column + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o as any); + return engine; + } + + it('stores the resolved id ARRAY and reads it back through the JSON column', async () => { + const e = await boot([AUTHOR, BOOK]); + const loader = new SeedLoaderService(e as any, metadataFor([AUTHOR, BOOK]), logger); + + const result = await loader.load({ seeds: SEEDS as any, config: SEED_CONFIG }); + expect(result.success).toBe(true); + expect(result.summary.totalErrored).toBe(0); + + const authors: any[] = await e.find('author', {}); + const alice = authors.find((r) => r.name === 'Alice')!; + const bob = authors.find((r) => r.name === 'Bob')!; + const book: any = (await e.find('book', {}))[0]; + + // The bug: `authors` was dropped entirely and the row landed without it. + expect(Array.isArray(book.authors)).toBe(true); + expect(book.authors).toEqual([alice.id, bob.id]); + }); + + it('replays idempotently — the array survives a second boot with no churn or duplicates', async () => { + const e = await boot([AUTHOR, BOOK]); + const loader = new SeedLoaderService(e as any, metadataFor([AUTHOR, BOOK]), logger); + + await loader.load({ seeds: SEEDS as any, config: SEED_CONFIG }); + const first: any = (await e.find('book', {}))[0]; + + // Second boot over the same database — the replay path compares the freshly + // resolved id array against the JSON-parsed stored one. + const replay = await loader.load({ seeds: SEEDS as any, config: SEED_CONFIG }); + + expect(replay.success).toBe(true); + expect(replay.summary.totalUpdated).toBe(0); // no updated_at churn + expect(replay.summary.totalSkipped).toBe(3); // 2 authors + 1 book + expect(await e.count('book', {})).toBe(1); // no duplicate row + + const after: any = (await e.find('book', {}))[0]; + expect(after.id).toBe(first.id); + expect(after.authors).toEqual(first.authors); + }); + + it('resolves against authors that already exist in the database', async () => { + const e = await boot([AUTHOR, BOOK]); + const alice = await e.insert('author', { name: 'Alice' }); + const bob = await e.insert('author', { name: 'Bob' }); + + const loader = new SeedLoaderService(e as any, metadataFor([AUTHOR, BOOK]), logger); + const result = await loader.load({ seeds: [SEEDS[1]] as any, config: SEED_CONFIG }); + + expect(result.success).toBe(true); + const book: any = (await e.find('book', {}))[0]; + expect(book.authors).toEqual([alice.id, bob.id]); + }); +}); From 6f19e47a211b6f9e0e10ba96be8f58db297778fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:48:36 +0000 Subject: [PATCH 4/7] feat(showcase): demonstrate a multi-value reference seed (#3911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- .../src/data/objects/field-zoo.object.ts | 12 ++++++++++++ examples/app-showcase/src/data/seed/index.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/examples/app-showcase/src/data/objects/field-zoo.object.ts b/examples/app-showcase/src/data/objects/field-zoo.object.ts index d20ae32827..409c7fa258 100644 --- a/examples/app-showcase/src/data/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/field-zoo.object.ts @@ -99,10 +99,22 @@ export const FieldZoo = ObjectSchema.create({ // ── Relational ─────────────────────────────────────────────────────── f_lookup: Field.lookup('showcase_account', { label: 'Lookup → Account' }), + // Multi-value reference — stores an ARRAY of account ids (JSON column). + // Seeded from an array of natural keys, one per element (framework#3911); + // this is the seedable half of the `multiple: true` reference surface — + // see `f_users` below for the half that a fresh boot cannot seed. + f_lookups: Field.lookup('showcase_account', { label: 'Lookup → Accounts (multiple)', multiple: true }), f_master_detail: Field.masterDetail('showcase_project', { label: 'Master-Detail → Project' }), f_tree: { type: 'tree', label: 'Tree (self/category)', reference: 'showcase_category' }, // ── User (lookup specialized to sys_user) ──────────────────────────── + // NOT seeded, and deliberately so: `sys_user` rows are created by SIGN-UP, + // not by seeds (see security/seed-approval-demo.ts — "users can't be + // seeded (they sign up)"), so on a fresh boot there is no human user for a + // natural key to resolve against. Authoring one here would make the whole + // showcase seed load report `success: false` on every first boot. The + // multi-value REFERENCE mechanics these fields share are demonstrated by + // `f_lookups` above; assign these two in the UI once you have signed up. f_user: Field.user({ label: 'User → sys_user (single)' }), f_users: Field.user({ label: 'Users (multiple)', multiple: true }), f_owner: Field.user({ label: 'Owner (current_user default)', defaultValue: 'current_user' }), diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 1bbf59d785..663b07fc36 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -256,6 +256,15 @@ const memberships = defineSeed(ProjectMembership, { // Relational/computed fields (lookup/master_detail/tree/record-map, formula/ // summary/autonumber) resolve or generate at runtime. `f_master_detail` is the // owning Project; `f_lookup` the Account — referenced by their seed externalId. +// +// `f_lookups` is the MULTI-VALUE reference case (framework#3911): a +// `multiple: true` lookup stores an array of ids, so the seed value is an +// ARRAY of natural keys and each element resolves independently. It is also +// the regression guard for that resolution — before the fix the array was +// rejected as a non-string reference and the field vanished from the row, +// leaving the specimen silently missing one relational type. +// The multi-value USER field (`f_users`) stays unseeded on purpose: sys_user +// rows come from sign-up, not seeds — see the note on the object. const fieldZoo = defineSeed(FieldZoo, { mode: 'upsert', externalId: 'name', @@ -272,7 +281,7 @@ const fieldZoo = defineSeed(FieldZoo, { f_date: '2026-06-17', f_datetime: '2026-06-17T14:30:00Z', f_time: '14:30', f_boolean: true, f_toggle: true, f_select: 'high', f_multiselect: ['red', 'green'], f_radio: 'yes', f_checkboxes: ['email', 'push'], f_tags: ['alpha', 'beta'], - f_lookup: 'Northwind', f_master_detail: 'Website Relaunch', + f_lookup: 'Northwind', f_lookups: ['Northwind', 'Contoso'], f_master_detail: 'Website Relaunch', f_location: { lat: 47.6062, lng: -122.3321 }, f_address: { street: '1 Main St', city: 'Seattle', state: 'WA', postal_code: '98101', country: 'US' }, f_code: '{\n "ok": true\n}', f_json: { nested: { k: 'v' }, list: [1, 2, 3] }, f_color: '#2563EB', f_rating: 4, f_slider: 60, f_progress: 80, From 23ff416c7c7a55b83ae2412b4ad8a29e015ddb3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:57:31 +0000 Subject: [PATCH 5/7] fix(seed-loader): count reference fields dropped from written rows (#3932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- .../seed-loader-dropped-reference-counter.md | 33 +++++++++++++++ content/docs/references/data/seed-loader.mdx | 1 + .../cli/src/utils/format.seed-summary.test.ts | 22 ++++++++++ packages/cli/src/utils/format.ts | 18 ++++++-- .../src/marketplace-install-local-plugin.ts | 7 +++- .../seed-loader-multi-value-reference.test.ts | 42 +++++++++++++++++++ packages/metadata-protocol/src/seed-loader.ts | 18 +++++++- packages/runtime/src/app-plugin.ts | 15 ++++++- packages/runtime/src/seed-summary.ts | 8 ++++ packages/spec/authorable-surface.json | 1 + packages/spec/src/data/seed-loader.zod.ts | 27 ++++++++++++ 11 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 .changeset/seed-loader-dropped-reference-counter.md diff --git a/.changeset/seed-loader-dropped-reference-counter.md b/.changeset/seed-loader-dropped-reference-counter.md new file mode 100644 index 0000000000..b8036107cc --- /dev/null +++ b/.changeset/seed-loader-dropped-reference-counter.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +"@objectstack/runtime": patch +"@objectstack/cli": patch +"@objectstack/cloud-connection": patch +--- + +fix(seed-loader): count reference fields dropped from rows that were still written + +The loader had two failure outcomes and only counted one. A record it cannot +write is counted 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 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 ([#3932](https://github.com/objectstack-ai/objectstack/issues/3932)). + +`SeedLoadResult.referencesDropped` and `SeedLoaderSummary.totalReferencesDropped` +now count it. It is deliberately **not** folded into `errored` — the row *was* +written, so that would break the `inserted + updated + skipped` reconciliation +against `total`. The banner names it separately: + +``` +⚠ Seeds: showcase 42 ok / 3 lost links ⚠ +``` + +Both counters are additive with a `0` default, so an existing producer or +consumer of `SeedLoaderResult` is unaffected. diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx index af2c5f8ead..257eaa7b3d 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -150,6 +150,7 @@ Result of loading a single dataset | **total** | `integer` | ✅ | Total records in dataset | | **referencesResolved** | `integer` | ✅ | References resolved via externalId | | **referencesDeferred** | `integer` | ✅ | References deferred to second pass | +| **referencesDropped** | `integer` | ✅ | Reference fields dropped from records that were still written | | **errors** | `{ sourceObject: string; field: string; targetObject: string; targetField: string; … }[]` | ✅ | Reference resolution errors | diff --git a/packages/cli/src/utils/format.seed-summary.test.ts b/packages/cli/src/utils/format.seed-summary.test.ts index f445e88960..9b47fd54e0 100644 --- a/packages/cli/src/utils/format.seed-summary.test.ts +++ b/packages/cli/src/utils/format.seed-summary.test.ts @@ -47,6 +47,28 @@ describe('printServerReady seed summary (#3415/#3430)', () => { expect(lines.some((l) => l.includes('OS_LOG_LEVEL=info'))).toBe(true); }); + it('screams about lost links even when every row landed (#3932)', () => { + // The "wrote the row, lost the link" case: nothing rejected, the row counts + // look perfect, an association is silently missing. This line used to read + // `showcase 42 rows` — clean, and wrong. + printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 42, droppedRefs: 3 })] }); + expect(seedLines()).toHaveLength(1); + expect(seedLines()[0]).toContain('showcase 42 ok / 3 lost links ⚠'); + expect(lines.some((l) => l.includes('OS_LOG_LEVEL=info'))).toBe(true); + }); + + it('reports rejected records and lost links together, singular-aware', () => { + printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 20, rejected: 1, droppedRefs: 1 })] }); + expect(seedLines()[0]).toContain('showcase 20 ok / 1 error / 1 lost link ⚠'); + }); + + it('stays quiet when no reference was dropped', () => { + printServerReady({ ...base, seeds: [s({ source: 'showcase', inserted: 42, droppedRefs: 0 })] }); + expect(seedLines()[0]).toContain('showcase 42 rows'); + expect(seedLines()[0]).not.toContain('lost link'); + expect(seedLines()[0]).not.toContain('⚠'); + }); + it('labels a marketplace package and marks a fresh-DB heal', () => { printServerReady({ ...base, diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index d0035a49a3..56dd7564a0 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -331,6 +331,12 @@ export interface SeedSourceSummary { skipped: number; /** Records dropped by validation/reference errors — the silent-loss case. */ rejected: number; + /** + * Reference FIELDS dropped from rows that WERE written (#3932). The row + * count stays healthy — the association is what went missing — so unless + * this is said out loud, nothing on this line hints at it. + */ + droppedRefs?: number; /** * Rows were (re)seeded onto a fresh/empty database during rehydrate — the * "swap the DB out from under an installed package" self-heal (#3430). @@ -460,20 +466,26 @@ function printSeedSummary(sources: SeedSourceSummary[]) { const shown = sources.filter((s) => { // Empty installs and rejections are ALWAYS shown (they're the whole point); // a source that touched no rows and had no problem is noise — drop it. - if (s.emptyInstall || s.rejected > 0) return true; + if (s.emptyInstall || s.rejected > 0 || (s.droppedRefs ?? 0) > 0) return true; return s.inserted + s.updated + s.skipped > 0; }); if (shown.length === 0) return; - const anyProblem = shown.some((s) => s.rejected > 0 || s.emptyInstall); + const anyProblem = shown.some((s) => s.rejected > 0 || (s.droppedRefs ?? 0) > 0 || s.emptyInstall); const fragment = (s: SeedSourceSummary): string => { const label = s.marketplace ? `${s.source}(marketplace)` : s.source; if (s.emptyInstall) return `${label} installed but 0 rows ⚠`; const ok = s.inserted + s.updated + s.skipped; + // A dropped reference leaves the row in place, so it never shows up in the + // row counts — name it separately or the line reads clean over a severed + // association (#3932). + const dropped = s.droppedRefs ?? 0; + const lostLinks = dropped > 0 ? ` / ${dropped} lost link${dropped === 1 ? '' : 's'}` : ''; if (s.rejected > 0) { - return `${label} ${ok} ok / ${s.rejected} error${s.rejected === 1 ? '' : 's'} ⚠`; + return `${label} ${ok} ok / ${s.rejected} error${s.rejected === 1 ? '' : 's'}${lostLinks} ⚠`; } + if (dropped > 0) return `${label} ${ok} ok${lostLinks} ⚠`; return `${label} ${ok} rows${s.healed ? ' (healed on fresh db)' : ''}`; }; diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index f9470d928a..30080bddb5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -263,6 +263,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { updated: summary.updated ?? 0, skipped: summary.skipped ?? 0, rejected: summary.errors ?? 0, + droppedRefs: summary.droppedRefs ?? 0, healed: true, }); } else { @@ -310,6 +311,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { updated: number; skipped: number; rejected: number; + droppedRefs?: number; healed?: boolean; emptyInstall?: boolean; }, @@ -1014,7 +1016,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx: PluginContext, datasets: any[], organizationId?: string, - ): Promise<{ inserted: number; updated: number; skipped: number; errors: number; errorSample?: string }> => { + ): Promise<{ inserted: number; updated: number; skipped: number; errors: number; droppedRefs: number; errorSample?: string }> => { const ql: any = ctx.getService('objectql'); let metadata: any; try { metadata = ctx.getService('metadata'); } catch { /* none */ } @@ -1040,6 +1042,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { updated: result.summary.totalUpdated, skipped: result.summary.totalSkipped ?? 0, errors: result.errors.length, + // Reference fields dropped from rows that WERE written (#3932) — + // invisible in every row count, so carried explicitly. + droppedRefs: result.summary.totalReferencesDropped ?? 0, // Surface the first write/resolution failure so the caller can // report WHY nothing landed (e.g. a locked DB, a missing table, // a failed validation) instead of a bare "0 rows". diff --git a/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts b/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts index 38812ee5a4..f59d5fe272 100644 --- a/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts +++ b/packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts @@ -277,6 +277,48 @@ describe('seed reference resolution — multi-value lookup (multiple: true)', () expect(store.book[0].reviewer).toBeUndefined(); expect(store.book[0].name).toBe('Refactoring'); expect(logger.warn).toHaveBeenCalled(); + + // framework#3932: the row WAS written, so `errored` stays 0 and the row + // counters all look healthy — the loss only shows up here. + expect(result.summary.totalErrored).toBe(0); + expect(result.summary.totalReferencesDropped).toBe(1); + expect(result.results.find((r) => r.object === 'book')!.referencesDropped).toBe(1); + }); + + it('counts a dropped reference field without disturbing the row counters (#3932)', async () => { + const { engine, store } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([ + { name: 'Refactoring', authors: [{ externalId: 'Alice' }] }, + { name: 'Clean Code', authors: ['Alice'] }, + ])] as any, + config: CONFIG, + }); + + const book = result.results.find((r) => r.object === 'book')!; + // Both rows were written — one just lost its association. + expect(store.book).toHaveLength(2); + expect(book.inserted).toBe(2); + expect(book.errored).toBe(0); + expect(book.referencesDropped).toBe(1); + // The reconciliation `errored` must not break: inserted + updated + skipped + // still accounts for every record in the dataset. + expect(book.inserted + book.updated + book.skipped + book.errored).toBe(book.total); + // Still a failed load — the counter reports damage, it does not excuse it. + expect(result.success).toBe(false); + }); + + it('reports zero dropped references on a clean load', async () => { + const { engine } = createEngine(SCHEMAS); + + const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({ + seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(result.summary.totalReferencesDropped).toBe(0); }); it('still rejects a wrapper object inside a multi-value array', async () => { diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 462bd29ce1..91e557545d 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -251,6 +251,13 @@ export class SeedLoaderService implements ISeedLoaderService { let errored = 0; let referencesResolved = 0; let referencesDeferred = 0; + /** + * Reference FIELDS dropped from records that were still written — the + * "wrote the row, lost the link" outcome. Kept apart from `errored` (which + * counts dropped RECORDS) so `inserted + updated + skipped + errored` + * still reconciles against `total`. See framework#3932. + */ + let referencesDropped = 0; const errors: ReferenceResolutionError[] = []; // Ensure the object's record map exists @@ -507,8 +514,12 @@ export class SeedLoaderService implements ISeedLoaderService { this.logger.warn(`[SeedLoader] ${error.message}`, { recordIndex: i }); // Drop the unwritable value so it never reaches the driver. Removing // the key (not writing null) matters on the upsert UPDATE path — see - // the deferred-reference note below. + // the deferred-reference note below. The row itself still gets + // written, so this is a dropped FIELD, not a dropped record — counted + // separately (framework#3932) or every count-driven surface, notably + // the CLI boot banner, reads clean over a severed association. delete record[ref.field]; + referencesDropped++; continue; } @@ -558,7 +569,9 @@ export class SeedLoaderService implements ISeedLoaderService { // Removing the key (not writing null) matters on the upsert UPDATE // path: an explicit null would overwrite the existing row's valid // reference, silently severing the link on every seed replay. + // Counted as a dropped FIELD (see the array branch above). delete record[ref.field]; + referencesDropped++; continue; } @@ -720,6 +733,7 @@ export class SeedLoaderService implements ISeedLoaderService { total: dataset.records.length, referencesResolved, referencesDeferred, + referencesDropped, errors, }; } @@ -1458,6 +1472,7 @@ export class SeedLoaderService implements ISeedLoaderService { totalErrored: 0, totalReferencesResolved: 0, totalReferencesDeferred: 0, + totalReferencesDropped: 0, circularDependencyCount: 0, durationMs, }, @@ -1480,6 +1495,7 @@ export class SeedLoaderService implements ISeedLoaderService { totalErrored: results.reduce((sum, r) => sum + r.errored, 0), totalReferencesResolved: results.reduce((sum, r) => sum + r.referencesResolved, 0), totalReferencesDeferred: results.reduce((sum, r) => sum + r.referencesDeferred, 0), + totalReferencesDropped: results.reduce((sum, r) => sum + (r.referencesDropped ?? 0), 0), circularDependencyCount: graph.circularDependencies.length, durationMs, }; diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 773c33c466..bd6205ffcc 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -893,6 +893,11 @@ export class AppPlugin implements Plugin { }); const result = await seedLoader.load(request); const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary; + // "Wrote the row, lost the link" (#3932): a reference field + // dropped from a row that WAS written moves none of the row + // counters, so it needs carrying separately or the banner + // reads clean over a severed association. + const totalRefsDropped = result.summary.totalReferencesDropped ?? 0; // #3415/#3430: stash a per-source outcome on the kernel so // the CLI boot banner can print a Seeds line. The logs below // never reach `os dev` output — info is under the default @@ -905,6 +910,7 @@ export class AppPlugin implements Plugin { updated: totalUpdated, skipped: totalSkipped, rejected: totalErrored, + droppedRefs: totalRefsDropped, }); if (result.success) { ctx.logger.info('[Seeder] Seed loading complete', { @@ -918,13 +924,20 @@ export class AppPlugin implements Plugin { // invisible (the summary only logged errors.length and // omitted totalErrored). Report the count AND each // actionable reason so broken seeds can't pass silently. + // Dropped reference FIELDS are named separately — the + // old line said "0 dropped record(s)" over a load that + // had severed associations, which is true and useless. + const lostLinks = totalRefsDropped > 0 + ? `, ${totalRefsDropped} dropped reference field(s) on written rows,` + : ''; ctx.logger.warn( - `[Seeder] Seed loading completed with ${totalErrored} dropped record(s) and ${result.errors.length} error(s) for ${appId}`, + `[Seeder] Seed loading completed with ${totalErrored} dropped record(s)${lostLinks} and ${result.errors.length} error(s) for ${appId}`, { inserted: totalInserted, updated: totalUpdated, skipped: totalSkipped, errored: totalErrored, + referencesDropped: totalRefsDropped, }, ); for (const e of result.errors.slice(0, 20)) { diff --git a/packages/runtime/src/seed-summary.ts b/packages/runtime/src/seed-summary.ts index 21b6dc6ac9..8ed8a9772c 100644 --- a/packages/runtime/src/seed-summary.ts +++ b/packages/runtime/src/seed-summary.ts @@ -31,6 +31,13 @@ export interface SeedSourceOutcome { skipped: number; /** Rows dropped by validation/reference errors — the silent-loss case. */ rejected: number; + /** + * Reference FIELDS dropped from rows that were still written — "wrote the + * row, lost the link" (framework#3932). A distinct loss from `rejected`: + * the row is there, so a row count looks healthy while an association is + * silently missing. Surfaced separately so the banner can say so. + */ + droppedRefs?: number; /** * The rows were (re)seeded onto a fresh/empty database during rehydrate — * the "swap the DB out from under an installed package" self-heal. Surfaced @@ -91,6 +98,7 @@ export function recordSeedOutcome(ctx: unknown, outcome: SeedSourceOutcome): voi existing.updated += outcome.updated; existing.skipped += outcome.skipped; existing.rejected += outcome.rejected; + existing.droppedRefs = (existing.droppedRefs ?? 0) + (outcome.droppedRefs ?? 0); existing.healed = existing.healed || outcome.healed; existing.emptyInstall = existing.emptyInstall || outcome.emptyInstall; } else { diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index c812723be9..073ddee4d2 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3752,6 +3752,7 @@ "data/SeedLoadResult:mode", "data/SeedLoadResult:object", "data/SeedLoadResult:referencesDeferred", + "data/SeedLoadResult:referencesDropped", "data/SeedLoadResult:referencesResolved", "data/SeedLoadResult:skipped", "data/SeedLoadResult:total", diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts index 2cf7480a95..a58862bbf4 100644 --- a/packages/spec/src/data/seed-loader.zod.ts +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -335,6 +335,26 @@ export const SeedLoadResultSchema = lazySchema(() => z.object({ /** Number of references deferred to pass 2 (circular dependencies) */ referencesDeferred: z.number().int().min(0).describe('References deferred to second pass'), + /** + * Number of reference FIELDS dropped from a record that was still written. + * + * `errored` already covers records the loader could not write as authored — + * dropped for an unresolvable reference, rejected by the engine, or left + * with a deferred reference that never resolved. This counter covers the + * remaining case, which nothing else counted: 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. The + * row is real but incomplete. + * + * Folding that into `errored` would break the `inserted + updated + skipped` + * reconciliation, so it gets its own counter. Without it a load that quietly + * severed N associations reported `totalErrored: 0`, and any count-driven + * surface — notably the CLI boot banner — read clean (framework#3932). + */ + referencesDropped: z.number().int().min(0).default(0) + .describe('Reference fields dropped from records that were still written'), + /** Reference resolution errors for this object */ errors: z.array(ReferenceResolutionErrorSchema).default([]) .describe('Reference resolution errors'), @@ -392,6 +412,13 @@ export const SeedLoaderResultSchema = lazySchema(() => z.object({ /** Total references deferred to second pass */ totalReferencesDeferred: z.number().int().min(0).describe('Total references deferred'), + /** + * Total reference fields dropped from rows that were still written — + * "wrote the row, lost the link". See `SeedLoadResult.referencesDropped`. + */ + totalReferencesDropped: z.number().int().min(0).default(0) + .describe('Total reference fields dropped from written records'), + /** Number of circular dependency chains detected */ circularDependencyCount: z.number().int().min(0).describe('Circular dependency chains detected'), From 815e209178d9e64f07b0220f87a1e1b889eb5e6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:15:33 +0000 Subject: [PATCH 6/7] fix(spec): let defineSeed type a multi-value lookup as an array of keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- .changeset/seed-loader-multi-value-lookup.md | 10 ++++++++ packages/spec/src/data/field.zod.ts | 24 ++++++++++++++++---- packages/spec/src/data/seed.zod.ts | 12 +++++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.changeset/seed-loader-multi-value-lookup.md b/.changeset/seed-loader-multi-value-lookup.md index cc74a62088..bb9bd4e454 100644 --- a/.changeset/seed-loader-multi-value-lookup.md +++ b/.changeset/seed-loader-multi-value-lookup.md @@ -29,3 +29,13 @@ rejected, now with advice an author can act on: declare the field `ReferenceResolution` (`@objectstack/spec/data`) gains an optional `multiple` flag carrying the field's array-ness into resolution; it is additive and defaulted-absent, so existing dependency graphs are unaffected. + +**Authoring types.** `defineSeed`'s per-field value type now widens a +`multiple: true` lookup to `string | string[] | null` (a lone string stays legal +— the loader accepts it as one-element shorthand). `master_detail` is inherently +single and is not widened, and an array on a single-value lookup is still a +compile error. To make that reachable, `Field.lookup` became generic over its +config (``) so `multiple: true` survives as a +literal instead of widening to `boolean`; the return type is intersected with +`FieldInput` so its optional surface is unchanged. Type-level only — the +returned object is byte-identical at runtime. diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d2562b3695..6c79826748 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -717,11 +717,25 @@ export const Field = { }, - lookup: (reference: string, config: FieldInput = {}) => ({ - type: 'lookup', - reference, - ...config - } as const), + /** + * Lookup — a reference to another object's record. + * + * Generic over `config` (with a `const` type parameter) so literal values + * SURVIVE into the returned field definition. A plain `config: FieldInput` + * widens `multiple: true` to `boolean`, which erases exactly the fact + * `defineSeed` needs to know: a `multiple: true` lookup is seeded from an + * ARRAY of natural keys, a single-value one from a lone string + * (framework#3911). Widening is purely a type-level change — the returned + * object is identical at runtime. + */ + lookup: ( + reference: string, + config: C = {} as C, + ): FieldInput & C & { readonly type: 'lookup'; readonly reference: string } => ({ + type: 'lookup', + reference, + ...config, + } as FieldInput & C & { readonly type: 'lookup'; readonly reference: string }), masterDetail: (reference: string, config: FieldInput = {}) => ({ type: 'master_detail', diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 13d4a1b23f..79c9519544 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -95,9 +95,19 @@ export type SeedImportMode = z.infer; * strings, bigints, buffers, and null" (silently masked on an always-empty * `:memory:` DB, fatal-looking on a persistent one). Constrain those fields to * `string | null` at compile time; every other field stays `unknown`. + * + * A `multiple: true` lookup is the one exception: it stores an ARRAY of ids, so + * it is seeded from an ARRAY of natural keys — one per element (framework#3911). + * A lone string stays legal there because the loader accepts it as one-element + * shorthand for the array shape the field stores. `master_detail` is inherently + * single-valued and so is never widened. */ type SeedFieldValue = - TFieldDef extends { type: 'lookup' | 'master_detail' } ? string | null : unknown; + TFieldDef extends { type: 'lookup' | 'master_detail' } + ? TFieldDef extends { multiple: true } + ? string | string[] | null + : string | null + : unknown; /** Shape of a single seed record, derived from the object's field definitions. */ type SeedRecord = { From d9dab205f6ff9f1f22e5de66e3a20b6c3555390b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:30:49 +0000 Subject: [PATCH 7/7] i18n(showcase): translate the new multi-value lookup label 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 Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah --- examples/app-showcase/src/system/translations/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index ff88e5e478..a40ffaaeaa 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -266,6 +266,7 @@ export const ShowcaseTranslationBundle = { }, showcase_field_zoo: { label: '字段动物园', pluralLabel: '字段动物园', + fields: { f_lookups: { label: '查找 → 客户(多值)' } }, }, }, },