Skip to content

Commit 8d4eae7

Browse files
claudeos-zhuang
authored andcommitted
fix(seed-loader): resolve natural-key arrays for multi-value lookups (#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
1 parent a831df1 commit 8d4eae7

6 files changed

Lines changed: 585 additions & 74 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix(seed-loader): resolve natural-key ARRAYS for multi-value lookups
7+
8+
A `multiple: true` lookup / `user` field stores an array of ids, so its seed
9+
value is an array of natural keys (`authors: ['Alice', 'Bob']`). Reference
10+
resolution only ever accepted a single string: the array tripped the
11+
"expected a natural-key string but got an object. Pass the target's `name`
12+
value as a plain string" guard — impossible advice for a field that holds
13+
several references — and was then DROPPED from the record. The row landed with
14+
the whole association missing and only a warn in the log
15+
([#3911](https://github.com/objectstack-ai/objectstack/issues/3911)).
16+
17+
Every element now resolves independently (in-load records first, then the
18+
database, then pass 2), and the field lands as an array of target ids. A lone
19+
string is accepted as one-element shorthand for the array shape the field
20+
stores. Deferral is all-or-nothing per field — a partially-resolved array is a
21+
corrupt association, so pass 2 re-resolves the whole authored array — and a key
22+
that never materializes is a reported load error naming that element, not a
23+
silent drop.
24+
25+
An array passed to a genuinely **single-value** reference field is still
26+
rejected, now with advice an author can act on: declare the field
27+
`multiple: true`, or pass one natural key.
28+
29+
`ReferenceResolution` (`@objectstack/spec/data`) gains an optional `multiple`
30+
flag carrying the field's array-ness into resolution; it is additive and
31+
defaulted-absent, so existing dependency graphs are unaffected.

content/docs/data-modeling/seed-data.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,36 @@ const contactsSeed = defineSeed(Contact, {
238238
export const SeedData = [accountsSeed, contactsSeed];
239239
```
240240

241+
### Multi-Value Lookups (`multiple: true`)
242+
243+
A lookup declared `multiple: true` stores an **array** of related record ids, so its
244+
seed value is an **array of natural keys**. Each element resolves independently.
245+
246+
```typescript
247+
const Book = ObjectSchema.create({
248+
name: 'book',
249+
fields: {
250+
name: Field.text({ label: 'Title', required: true }),
251+
authors: Field.lookup('author', { label: 'Authors', multiple: true }),
252+
},
253+
});
254+
255+
const booksSeed = defineSeed(Book, {
256+
externalId: 'name',
257+
records: [
258+
{ name: 'Refactoring', authors: ['Alice', 'Bob'] }, // one natural key per author
259+
],
260+
});
261+
```
262+
263+
A lone string is accepted as one-element shorthand (`authors: 'Alice'` stores
264+
`[<alice id>]`). Resolution is **all-or-nothing per field**: if any element is still
265+
missing, the whole array is deferred to pass 2 rather than written half-resolved, and
266+
a key that never materializes is reported as a load error naming that element.
267+
268+
Passing an array to a **single-value** lookup is rejected with a load error — declare
269+
the field `multiple: true`, or pass one natural key.
270+
241271
### Composite `externalId` — Join / Junction Tables
242272

243273
A join (junction) table linking two objects many-to-many has **no single-field
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { SeedLoaderService } from './seed-loader';
5+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6+
7+
/**
8+
* Multi-value reference resolution (`Field.lookup(..., { multiple: true })`).
9+
*
10+
* The seed value for a multi-value lookup is an ARRAY of natural keys —
11+
* `authors: ['Alice', 'Bob']`. Reference resolution used to reject anything
12+
* non-string outright ("expected a natural-key string but got an object.
13+
* Pass the target's name value as a plain string"), which for a `multiple`
14+
* field is impossible advice: one string cannot express several associations.
15+
* The array was then DROPPED from the record, so the row landed with the whole
16+
* relationship missing and only a warn in the log (framework#3911).
17+
*
18+
* These tests pin the fix: every element resolves independently, the field
19+
* lands as an array of target ids, deferral is all-or-nothing per field, and a
20+
* genuinely single-value field still rejects an array — loudly and with advice
21+
* an author can act on.
22+
*/
23+
24+
function createLogger() {
25+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
26+
}
27+
28+
/** Faithful in-memory engine (where-filtering find, array insert) — same shape
29+
* the other seed-loader suites use, plus a `getSchema` registry. */
30+
function createEngine(schemas: Record<string, any>) {
31+
const store: Record<string, any[]> = {};
32+
let idCounter = 0;
33+
34+
const engine = {
35+
find: vi.fn(async (objectName: string, query?: any) => {
36+
let records = store[objectName] || [];
37+
if (query?.where) {
38+
records = records.filter((r) =>
39+
Object.entries(query.where).every(([k, v]) => r[k] === v),
40+
);
41+
}
42+
if (typeof query?.limit === 'number') records = records.slice(0, query.limit);
43+
return records.map((r) => ({ ...r }));
44+
}),
45+
findOne: vi.fn(async (objectName: string, query?: any) => {
46+
const rows = await (engine.find as any)(objectName, { ...query, limit: 1 });
47+
return rows[0] ?? null;
48+
}),
49+
insert: vi.fn(async (objectName: string, data: any) => {
50+
if (!store[objectName]) store[objectName] = [];
51+
if (Array.isArray(data)) {
52+
const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d }));
53+
store[objectName].push(...records);
54+
return records;
55+
}
56+
const record = { id: `gen-${++idCounter}`, ...data };
57+
store[objectName].push(record);
58+
return record;
59+
}),
60+
update: vi.fn(async (objectName: string, data: any) => {
61+
const records = store[objectName] || [];
62+
const idx = records.findIndex((r) => r.id === data.id);
63+
if (idx >= 0) {
64+
records[idx] = { ...records[idx], ...data };
65+
return records[idx];
66+
}
67+
return data;
68+
}),
69+
delete: vi.fn(async () => ({ deleted: 1 })),
70+
count: vi.fn(async (objectName: string) => (store[objectName] || []).length),
71+
aggregate: vi.fn(async () => []),
72+
getSchema: vi.fn((objectName: string) => schemas[objectName]),
73+
} as unknown as IDataEngine & { getSchema: ReturnType<typeof vi.fn> };
74+
75+
return { engine, store };
76+
}
77+
78+
function createEmptyMetadata(): IMetadataService {
79+
return {
80+
getObject: vi.fn(async () => undefined),
81+
listObjects: vi.fn(async () => []),
82+
register: vi.fn(async () => {}),
83+
get: vi.fn(async () => undefined),
84+
list: vi.fn(async () => []),
85+
unregister: vi.fn(async () => {}),
86+
exists: vi.fn(async () => false),
87+
listNames: vi.fn(async () => []),
88+
} as unknown as IMetadataService;
89+
}
90+
91+
/** The issue's minimal repro: a book with several authors. `reviewer` is the
92+
* single-value control that must keep rejecting an array. */
93+
const SCHEMAS: Record<string, any> = {
94+
author: {
95+
name: 'author',
96+
fields: { name: { type: 'text', required: true } },
97+
},
98+
book: {
99+
name: 'book',
100+
fields: {
101+
name: { type: 'text', required: true },
102+
authors: { type: 'lookup', reference: 'author', multiple: true },
103+
reviewer: { type: 'lookup', reference: 'author' },
104+
},
105+
},
106+
};
107+
108+
const CONFIG = {
109+
dryRun: false,
110+
haltOnError: false,
111+
multiPass: true,
112+
defaultMode: 'upsert',
113+
batchSize: 1000,
114+
transaction: false,
115+
} as any;
116+
117+
const AUTHOR_SEED = {
118+
object: 'author',
119+
externalId: 'name',
120+
mode: 'upsert',
121+
env: ['prod', 'dev', 'test'],
122+
records: [{ name: 'Alice' }, { name: 'Bob' }],
123+
};
124+
125+
const bookSeed = (records: any[]) => ({
126+
object: 'book',
127+
externalId: 'name',
128+
mode: 'upsert',
129+
env: ['prod', 'dev', 'test'],
130+
records,
131+
});
132+
133+
describe('seed reference resolution — multi-value lookup (multiple: true)', () => {
134+
it('resolves every natural key in the array to a target id', async () => {
135+
const { engine, store } = createEngine(SCHEMAS);
136+
137+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
138+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any,
139+
config: CONFIG,
140+
});
141+
142+
expect(result.success).toBe(true);
143+
expect(result.summary.totalErrored).toBe(0);
144+
145+
const alice = store.author.find((r) => r.name === 'Alice')!;
146+
const bob = store.author.find((r) => r.name === 'Bob')!;
147+
const book = store.book.find((r) => r.name === 'Refactoring')!;
148+
149+
// The bug: `authors` was deleted from the record entirely.
150+
expect(book.authors).toEqual([alice.id, bob.id]);
151+
// Order is the authored order — a stored array is ordered data.
152+
expect(book.authors[0]).toBe(alice.id);
153+
});
154+
155+
it('resolves against rows that already exist in the database', async () => {
156+
const { engine, store } = createEngine(SCHEMAS);
157+
store.author = [
158+
{ id: 'author-existing-1', name: 'Alice' },
159+
{ id: 'author-existing-2', name: 'Bob' },
160+
];
161+
162+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
163+
seeds: [bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any,
164+
config: CONFIG,
165+
});
166+
167+
expect(result.success).toBe(true);
168+
expect(store.book[0].authors).toEqual(['author-existing-1', 'author-existing-2']);
169+
expect(result.summary.totalReferencesResolved).toBe(2);
170+
});
171+
172+
it('normalizes a lone natural key to the array shape the field stores', async () => {
173+
const { engine, store } = createEngine(SCHEMAS);
174+
175+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
176+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: 'Alice' }])] as any,
177+
config: CONFIG,
178+
});
179+
180+
expect(result.success).toBe(true);
181+
const alice = store.author.find((r) => r.name === 'Alice')!;
182+
expect(store.book[0].authors).toEqual([alice.id]);
183+
});
184+
185+
it('passes internal ids through untouched, mixed with natural keys', async () => {
186+
const { engine, store } = createEngine(SCHEMAS);
187+
const existingId = '11111111-2222-4333-8444-555555555555'; // UUID → looksLikeInternalId
188+
store.author = [{ id: existingId, name: 'Carol' }];
189+
190+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
191+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: [existingId, 'Bob'] }])] as any,
192+
config: CONFIG,
193+
});
194+
195+
expect(result.success).toBe(true);
196+
const bob = store.author.find((r) => r.name === 'Bob')!;
197+
expect(store.book[0].authors).toEqual([existingId, bob.id]);
198+
});
199+
200+
it('back-fills the whole array in pass 2 when the targets load later (circular graph)', async () => {
201+
// `author.favorite_book → book` and `book.authors → author` form a cycle,
202+
// so `book` loads before its authors exist and defers the WHOLE array.
203+
const cyclic = {
204+
author: {
205+
name: 'author',
206+
fields: {
207+
name: { type: 'text', required: true },
208+
favorite_book: { type: 'lookup', reference: 'book' },
209+
},
210+
},
211+
book: SCHEMAS.book,
212+
};
213+
const { engine, store } = createEngine(cyclic);
214+
215+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
216+
seeds: [
217+
bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }]),
218+
{ ...AUTHOR_SEED, records: [{ name: 'Alice', favorite_book: 'Refactoring' }, { name: 'Bob' }] },
219+
] as any,
220+
config: CONFIG,
221+
});
222+
223+
expect(result.success).toBe(true);
224+
expect(result.summary.totalErrored).toBe(0);
225+
226+
const alice = store.author.find((r) => r.name === 'Alice')!;
227+
const bob = store.author.find((r) => r.name === 'Bob')!;
228+
const book = store.book.find((r) => r.name === 'Refactoring')!;
229+
230+
expect(book.authors).toEqual([alice.id, bob.id]);
231+
expect(alice.favorite_book).toBe(book.id);
232+
});
233+
234+
it('defers the array as a WHOLE — never writes a half-resolved association', async () => {
235+
const { engine, store } = createEngine(SCHEMAS);
236+
237+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
238+
// 'Nobody' never materializes, so the field stays deferred and then errors.
239+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Nobody'] }])] as any,
240+
config: CONFIG,
241+
});
242+
243+
expect(result.success).toBe(false);
244+
// The partial `[alice.id]` must NOT have been written.
245+
expect(store.book[0].authors).toBeUndefined();
246+
// The error names the element at fault, not the whole array.
247+
expect(result.errors.some((e) => e.message.includes("'Nobody'"))).toBe(true);
248+
});
249+
250+
it('drops the record (loudly) when an element cannot resolve and no pass 2 will run', async () => {
251+
const { engine, store } = createEngine(SCHEMAS);
252+
253+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
254+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Nobody'] }])] as any,
255+
config: { ...CONFIG, multiPass: false },
256+
});
257+
258+
expect(result.success).toBe(false);
259+
expect(store.book).toBeUndefined();
260+
expect(result.errors.some((e) => e.message.includes('Cannot resolve reference: book.authors'))).toBe(true);
261+
});
262+
263+
it('rejects an array on a SINGLE-value reference field with actionable advice', async () => {
264+
const { engine, store } = createEngine(SCHEMAS);
265+
const logger = createLogger();
266+
267+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), logger).load({
268+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', reviewer: ['Alice', 'Bob'] }])] as any,
269+
config: CONFIG,
270+
});
271+
272+
expect(result.success).toBe(false);
273+
const message = result.errors.find((e) => e.field === 'reviewer')!.message;
274+
expect(message).toContain('but got an array');
275+
expect(message).toContain('multiple: true');
276+
// The unwritable value never reaches the driver; the record still lands.
277+
expect(store.book[0].reviewer).toBeUndefined();
278+
expect(store.book[0].name).toBe('Refactoring');
279+
expect(logger.warn).toHaveBeenCalled();
280+
});
281+
282+
it('still rejects a wrapper object inside a multi-value array', async () => {
283+
const { engine } = createEngine(SCHEMAS);
284+
285+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
286+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: [{ externalId: 'Alice' }] }])] as any,
287+
config: CONFIG,
288+
});
289+
290+
expect(result.success).toBe(false);
291+
const message = result.errors.find((e) => e.field === 'authors')!.message;
292+
expect(message).toContain('Pass the natural key directly: authors: "Alice"');
293+
});
294+
295+
it('replays idempotently — a resolved array is not rewritten on the second load', async () => {
296+
const { engine, store } = createEngine(SCHEMAS);
297+
const seeds = [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice', 'Bob'] }])] as any;
298+
const loader = new SeedLoaderService(engine, createEmptyMetadata(), createLogger());
299+
300+
await loader.load({ seeds, config: CONFIG });
301+
const first = store.book[0].authors;
302+
303+
const replay = await loader.load({ seeds, config: CONFIG });
304+
305+
expect(replay.success).toBe(true);
306+
// Replay compares the resolved array against the stored one — no churn.
307+
expect(replay.summary.totalUpdated).toBe(0);
308+
expect(replay.summary.totalSkipped).toBe(3);
309+
expect(store.book).toHaveLength(1);
310+
expect(store.book[0].authors).toEqual(first);
311+
});
312+
313+
it('reports the array shape in the dependency graph', async () => {
314+
const { engine } = createEngine(SCHEMAS);
315+
316+
const result = await new SeedLoaderService(engine, createEmptyMetadata(), createLogger()).load({
317+
seeds: [AUTHOR_SEED, bookSeed([{ name: 'Refactoring', authors: ['Alice'] }])] as any,
318+
config: CONFIG,
319+
});
320+
321+
const bookNode = result.dependencyGraph.nodes.find((n) => n.object === 'book')!;
322+
expect(bookNode.references.find((r) => r.field === 'authors')!.multiple).toBe(true);
323+
expect(bookNode.references.find((r) => r.field === 'reviewer')!.multiple).toBeUndefined();
324+
});
325+
});

0 commit comments

Comments
 (0)