@cosmicdrift/kumiko-framework 0.180.0 → 0.181.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.180.0",
3
+ "version": "0.181.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.180.0",
185
+ "@cosmicdrift/kumiko-types": "0.181.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.180.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.181.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -25,6 +25,11 @@ const entityWithDefaults = createEntity({
25
25
  rate: { type: "decimal", precision: 6, scale: 4, required: true, default: 1.5 },
26
26
  price: { type: "money" },
27
27
  meta: { type: "embedded", fields: {} },
28
+ postings: {
29
+ type: "embedded",
30
+ multiple: true,
31
+ schema: { accountId: { type: "text", required: true } },
32
+ },
28
33
  startedAt: { type: "timestamp", required: true },
29
34
  },
30
35
  });
@@ -63,6 +68,13 @@ describe("buildEntityTable ↔ deriveEntityTableMeta lock-step", () => {
63
68
  expect(cols.get("rate")?.defaultSql).toBe("1.5");
64
69
  });
65
70
 
71
+ test("embedded default is `{}`, embedded-list default is `[]` on both paths", () => {
72
+ const cols = new Map((fromBuilder?.columns ?? []).map((c) => [c.name, c]));
73
+ expect(cols.get("meta")?.defaultSql).toBe("'{}'::jsonb");
74
+ expect(cols.get("postings")?.defaultSql).toBe("'[]'::jsonb");
75
+ expect(cols.get("postings")?.notNull).toBe(true);
76
+ });
77
+
66
78
  test("decimal field maps to numeric(precision,scale) on both paths", () => {
67
79
  const cols = new Map((fromBuilder?.columns ?? []).map((c) => [c.name, c]));
68
80
  expect(cols.get("rate")?.pgType).toBe("numeric(6,4)");
@@ -189,7 +189,14 @@ function fieldToColumnMeta(
189
189
  ];
190
190
  }
191
191
  case "embedded":
192
- return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'{}'::jsonb" }];
192
+ return [
193
+ {
194
+ name: snake,
195
+ pgType: "jsonb",
196
+ notNull: true,
197
+ defaultSql: field.multiple === true ? "'[]'::jsonb" : "'{}'::jsonb",
198
+ },
199
+ ];
193
200
  case "jsonb":
194
201
  return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'{}'::jsonb" }];
195
202
  case "date":
@@ -156,7 +156,10 @@ function fieldToColumns(
156
156
  // strukturell never-null macht. Wer optional-embedded möchte (=
157
157
  // "Feld komplett weglassen können") modelliert das über ein
158
158
  // wrapper-feld mit boolean-flag oder discriminierte-union.
159
- return { [name]: jsonb(snakeName).default({}).notNull() };
159
+ // multiple the value is a list of rows, so the empty default is `[]`.
160
+ return field.multiple === true
161
+ ? { [name]: jsonb(snakeName).default([]).notNull() }
162
+ : { [name]: jsonb(snakeName).default({}).notNull() };
160
163
  case "jsonb":
161
164
  // Free-form jsonb — keys nicht schema-validated. Default `{}`, NOT NULL
162
165
  // (analog zu embedded). Use-case: custom-fields-Bundle's host-entity-
@@ -324,28 +327,35 @@ type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
324
327
  ? F extends { required: true }
325
328
  ? { readonly [P in K]: Col<string> }
326
329
  : { readonly [P in K]: NullCol<string> }
327
- : F extends { type: "embedded" }
328
- ? // jsonb default `{}`, immer notNull
329
- { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
330
- : F extends { type: "date" | "timestamp" }
331
- ? F extends { required: true }
332
- ? { readonly [P in K]: Col<Temporal.Instant> }
333
- : { readonly [P in K]: NullCol<Temporal.Instant> }
334
- : F extends { type: "locatedTimestamp" }
330
+ : F extends { type: "embedded"; multiple: true }
331
+ ? // jsonb default `[]`, immer notNull. ponytail: the row
332
+ // type stays untyped like single-embedded; deriving it
333
+ // from `schema` needs a generic createEmbeddedListField
334
+ // that captures the literal schema — worth it once a
335
+ // consumer actually reads rows off the table type.
336
+ { readonly [P in K]: Col<readonly Readonly<Record<string, unknown>>[]> }
337
+ : F extends { type: "embedded" }
338
+ ? // jsonb default `{}`, immer notNull
339
+ { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
340
+ : F extends { type: "date" | "timestamp" }
335
341
  ? F extends { required: true }
336
- ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
337
- readonly [P in `${K}Tz`]: Col<string>;
338
- }
339
- : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
340
- readonly [P in `${K}Tz`]: NullCol<string>;
341
- }
342
- : F extends { type: "file" | "image" }
342
+ ? { readonly [P in K]: Col<Temporal.Instant> }
343
+ : { readonly [P in K]: NullCol<Temporal.Instant> }
344
+ : F extends { type: "locatedTimestamp" }
343
345
  ? F extends { required: true }
344
- ? { readonly [P in K]: Col<string> }
345
- : { readonly [P in K]: NullCol<string> }
346
- : F extends { type: "files" | "images" }
347
- ? Record<never, never>
348
- : never;
346
+ ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
347
+ readonly [P in `${K}Tz`]: Col<string>;
348
+ }
349
+ : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
350
+ readonly [P in `${K}Tz`]: NullCol<string>;
351
+ }
352
+ : F extends { type: "file" | "image" }
353
+ ? F extends { required: true }
354
+ ? { readonly [P in K]: Col<string> }
355
+ : { readonly [P in K]: NullCol<string> }
356
+ : F extends { type: "files" | "images" }
357
+ ? Record<never, never>
358
+ : never;
349
359
 
350
360
  type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
351
361
  k: infer I,
@@ -905,17 +905,40 @@ describe("createApp", () => {
905
905
  fields: {
906
906
  address: createEmbeddedField({
907
907
  // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
908
- street: { type: "money" as any },
908
+ street: { type: "uuid" as any },
909
909
  }),
910
910
  },
911
911
  }),
912
912
  );
913
913
  });
914
914
  expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
915
- 'invalid type "money"',
915
+ 'invalid type "uuid"',
916
916
  );
917
917
  });
918
918
 
919
+ test("rejects decimal sub-field with invalid scale", () => {
920
+ const featureWithScale = (scale: number) =>
921
+ defineFeature("test", (r) => {
922
+ r.entity(
923
+ "doc",
924
+ createEntity({
925
+ table: "Docs",
926
+ fields: {
927
+ items: createEmbeddedField({
928
+ qty: { type: "decimal", scale },
929
+ }),
930
+ },
931
+ }),
932
+ );
933
+ });
934
+ for (const scale of [-1, 1.5, 16]) {
935
+ expect(() => createApp({ roles: ["Admin"], features: [featureWithScale(scale)] })).toThrow(
936
+ `invalid scale ${scale}`,
937
+ );
938
+ }
939
+ expect(() => createApp({ roles: ["Admin"], features: [featureWithScale(3)] })).not.toThrow();
940
+ });
941
+
919
942
  test("accepts valid embedded field with all sub-field types", () => {
920
943
  const feature = defineFeature("test", (r) => {
921
944
  r.entity(
@@ -928,6 +951,8 @@ describe("createApp", () => {
928
951
  count: { type: "number" },
929
952
  active: { type: "boolean" },
930
953
  created: { type: "date" },
954
+ amount: { type: "money" },
955
+ qty: { type: "decimal", scale: 3 },
931
956
  }),
932
957
  },
933
958
  }),
@@ -47,6 +47,33 @@ describe("filterReadFields", () => {
47
47
  const filteredForAdmin = filterReadFields(entityWithPii, row, admin);
48
48
  expect(filteredForAdmin["iban"]).toBe("DE89370400440532013000");
49
49
  });
50
+
51
+ test("filters each row of an embedded list, keeping it an array", () => {
52
+ const entityWithLines: EntityDefinition = {
53
+ fields: {
54
+ lines: {
55
+ type: "embedded",
56
+ multiple: true,
57
+ schema: {
58
+ accountId: { type: "text" },
59
+ internalNote: { type: "text", access: { read: { admin: "all" } } },
60
+ },
61
+ },
62
+ },
63
+ };
64
+ const row = {
65
+ lines: [
66
+ { accountId: "bank", internalNote: "review me" },
67
+ { accountId: "rent", internalNote: "and me" },
68
+ ],
69
+ };
70
+
71
+ expect(filterReadFields(entityWithLines, row, editor)["lines"]).toEqual([
72
+ { accountId: "bank" },
73
+ { accountId: "rent" },
74
+ ]);
75
+ expect(filterReadFields(entityWithLines, row, admin)["lines"]).toEqual(row.lines);
76
+ });
50
77
  });
51
78
 
52
79
  describe("checkWriteFieldRoles", () => {
@@ -3,6 +3,7 @@ import {
3
3
  createBooleanField,
4
4
  createDateField,
5
5
  createEmbeddedField,
6
+ createEmbeddedListField,
6
7
  createEntity,
7
8
  createFileField,
8
9
  createFilesField,
@@ -371,6 +372,108 @@ describe("buildInsertSchema", () => {
371
372
  expect(schema.safeParse({}).success).toBe(false);
372
373
  });
373
374
 
375
+ test("embedded-list field validates every row against the schema", () => {
376
+ const entity = createEntity({
377
+ table: "Test",
378
+ fields: {
379
+ lines: createEmbeddedListField({
380
+ accountId: { type: "text", required: true },
381
+ amount: { type: "number", required: true },
382
+ note: { type: "text" },
383
+ }),
384
+ },
385
+ });
386
+ const schema = buildInsertSchema(entity);
387
+ expect(
388
+ schema.safeParse({
389
+ lines: [
390
+ { accountId: "bank", amount: 100 },
391
+ { accountId: "rent", amount: -100, note: "Januar" },
392
+ ],
393
+ }).success,
394
+ ).toBe(true);
395
+ // second row is missing `amount` — a per-row check the free jsonb field
396
+ // this replaces could not make
397
+ expect(
398
+ schema.safeParse({ lines: [{ accountId: "bank", amount: 100 }, { accountId: "rent" }] })
399
+ .success,
400
+ ).toBe(false);
401
+ expect(schema.safeParse({ lines: [{ accountId: "bank", amount: "100" }] }).success).toBe(false);
402
+ });
403
+
404
+ test("embedded-list field rejects a bare object", () => {
405
+ const entity = createEntity({
406
+ table: "Test",
407
+ fields: { lines: createEmbeddedListField({ accountId: { type: "text", required: true } }) },
408
+ });
409
+ const schema = buildInsertSchema(entity);
410
+ expect(schema.safeParse({ lines: { accountId: "bank" } }).success).toBe(false);
411
+ });
412
+
413
+ test("optional embedded-list accepts an empty list, required one does not", () => {
414
+ const optional = buildInsertSchema(
415
+ createEntity({
416
+ table: "Test",
417
+ fields: { lines: createEmbeddedListField({ accountId: { type: "text" } }) },
418
+ }),
419
+ );
420
+ expect(optional.safeParse({ lines: [] }).success).toBe(true);
421
+ expect(optional.safeParse({}).success).toBe(true);
422
+
423
+ const required = buildInsertSchema(
424
+ createEntity({
425
+ table: "Test",
426
+ fields: {
427
+ lines: createEmbeddedListField({ accountId: { type: "text" } }, { required: true }),
428
+ },
429
+ }),
430
+ );
431
+ expect(required.safeParse({ lines: [] }).success).toBe(false);
432
+ expect(required.safeParse({}).success).toBe(false);
433
+ expect(required.safeParse({ lines: [{ accountId: "bank" }] }).success).toBe(true);
434
+ });
435
+
436
+ test("money sub-field accepts signed integer minor units, rejects fractions", () => {
437
+ const entity = createEntity({
438
+ table: "Test",
439
+ fields: {
440
+ lines: createEmbeddedListField({
441
+ accountId: { type: "text", required: true },
442
+ amount: { type: "money", required: true },
443
+ }),
444
+ },
445
+ });
446
+ const schema = buildInsertSchema(entity);
447
+ const line = (amount: number) => ({ lines: [{ accountId: "bank", amount }] });
448
+ expect(schema.safeParse(line(100)).success).toBe(true);
449
+ expect(schema.safeParse(line(-100)).success).toBe(true);
450
+ expect(schema.safeParse(line(Number.MAX_SAFE_INTEGER)).success).toBe(true);
451
+ // a fractional amount is the Euro-vs-Cent confusion the type exists to catch
452
+ expect(schema.safeParse(line(10.5)).success).toBe(false);
453
+ expect(schema.safeParse(line(Number.MAX_SAFE_INTEGER + 1)).success).toBe(false);
454
+ });
455
+
456
+ test("decimal sub-field bounds the value to its scale", () => {
457
+ const entity = createEntity({
458
+ table: "Test",
459
+ fields: {
460
+ items: createEmbeddedListField({
461
+ qty: { type: "decimal", scale: 2, required: true },
462
+ }),
463
+ },
464
+ });
465
+ const schema = buildInsertSchema(entity);
466
+ const row = (qty: number) => ({ items: [{ qty }] });
467
+ expect(schema.safeParse(row(1.25)).success).toBe(true);
468
+ expect(schema.safeParse(row(-3.5)).success).toBe(true);
469
+ // float artifact of an in-scale computation must pass (same contract as
470
+ // the top-level decimal field)
471
+ expect(schema.safeParse(row(0.1 + 0.2)).success).toBe(true);
472
+ expect(schema.safeParse(row(0.305)).success).toBe(false);
473
+ // scaled by 10^2 this leaves the safe-integer range
474
+ expect(schema.safeParse(row(Number.MAX_SAFE_INTEGER)).success).toBe(false);
475
+ });
476
+
374
477
  test("tz field validates against the IANA zone list", () => {
375
478
  const entity = createEntity({
376
479
  table: "Test",
@@ -194,6 +194,52 @@ describe("buildSearchDocument — contributor precedence (base fields win)", ()
194
194
  });
195
195
  });
196
196
 
197
+ describe("buildSearchDocument — searchable embedded sub-fields", () => {
198
+ function registryWithLines(multiple: boolean) {
199
+ const feature = defineFeature("test", (r) => {
200
+ r.entity(
201
+ "invoice",
202
+ createEntity({
203
+ table: "invoices",
204
+ fields: {
205
+ lines: {
206
+ type: "embedded",
207
+ ...(multiple ? { multiple: true } : {}),
208
+ schema: { description: { type: "text", searchable: true } },
209
+ },
210
+ },
211
+ }),
212
+ );
213
+ });
214
+ return createRegistry([feature]);
215
+ }
216
+
217
+ test("a list-embedded sub-field indexes one value per row", async () => {
218
+ const doc = await buildSearchDocument(
219
+ "invoice",
220
+ "i1",
221
+ { lines: [{ description: "Kaltmiete" }, { description: "Stellplatz" }] },
222
+ registryWithLines(true),
223
+ );
224
+ expect(doc?.fields["lines_description"]).toEqual(["Kaltmiete", "Stellplatz"]);
225
+ });
226
+
227
+ test("an empty list contributes no indexed value", async () => {
228
+ const doc = await buildSearchDocument("invoice", "i1", { lines: [] }, registryWithLines(true));
229
+ expect(doc?.fields["lines_description"]).toBeUndefined();
230
+ });
231
+
232
+ test("a single-embedded sub-field still indexes the scalar", async () => {
233
+ const doc = await buildSearchDocument(
234
+ "invoice",
235
+ "i1",
236
+ { lines: { description: "Kaltmiete" } },
237
+ registryWithLines(false),
238
+ );
239
+ expect(doc?.fields["lines_description"]).toBe("Kaltmiete");
240
+ });
241
+ });
242
+
197
243
  describe("Boot-Validation", () => {
198
244
  test("rejects searchPayloadExtension on unknown entity-name (sibling to entity-hooks)", () => {
199
245
  expect(() =>
@@ -364,7 +364,13 @@ export function validateFileFields(feature: FeatureDefinition): boolean {
364
364
 
365
365
  // --- Embedded field validation ---
366
366
 
367
- const VALID_EMBEDDED_SUB_TYPES = new Set(["text", "number", "boolean", "date"]);
367
+ const VALID_EMBEDDED_SUB_TYPES = new Set(["text", "number", "boolean", "date", "money", "decimal"]);
368
+
369
+ // 15 is where 10^scale exhausts a double's integer range — beyond it the
370
+ // scale check in the write schema could no longer hold.
371
+ function isValidEmbeddedDecimalScale(scale: number): boolean {
372
+ return Number.isInteger(scale) && scale >= 0 && scale <= 15;
373
+ }
368
374
 
369
375
  // Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef-Validation.
370
376
  // 1) referenced entity existiert (same-feature OR cross-feature
@@ -456,6 +462,11 @@ export function validateEmbeddedFields(feature: FeatureDefinition): void {
456
462
  `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid type "${subField.type}". Allowed: ${[...VALID_EMBEDDED_SUB_TYPES].join(", ")}`,
457
463
  );
458
464
  }
465
+ if (subField.type === "decimal" && !isValidEmbeddedDecimalScale(subField.scale)) {
466
+ throw new Error(
467
+ `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid scale ${subField.scale}. Must be an integer between 0 and 15.`,
468
+ );
469
+ }
459
470
  }
460
471
  }
461
472
  }
@@ -210,6 +210,27 @@ export function createEmbeddedField(
210
210
  };
211
211
  }
212
212
 
213
+ // A list of like-shaped objects — see `EmbeddedFieldDef.multiple` for when an
214
+ // embedded list is the right model and when the rows belong in their own
215
+ // entity. `required: true` means the insert must carry the key AND at least
216
+ // one row (analogous to multiSelect); the column itself is never null, it
217
+ // defaults to `[]`.
218
+ //
219
+ // The `multiple: true` literal is part of the return type because the
220
+ // table-builder's ColumnsForField branches on it — a widened `boolean` would
221
+ // silently fall back to the single-object column type.
222
+ export function createEmbeddedListField(
223
+ schema: EmbeddedFieldDef["schema"],
224
+ overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | "multiple">>,
225
+ ): EmbeddedFieldDef & { multiple: true } {
226
+ return {
227
+ type: "embedded",
228
+ schema,
229
+ ...overrides,
230
+ multiple: true,
231
+ };
232
+ }
233
+
213
234
  // Free-form jsonb-Spalte — siehe `JsonbFieldDef`-Doku. Schema-less, default
214
235
  // `{}`, NOT NULL. Hauptnutzer: custom-fields-Bundle (host-entity's
215
236
  // `customFields`-Spalte). Andere valid uses: tenant-config-blobs, AI-
@@ -43,18 +43,24 @@ export function filterReadFields(
43
43
  continue; // entire field stripped (masked instead, for piiEncrypted)
44
44
  }
45
45
 
46
- // For embedded fields: filter sub-fields with access restrictions
46
+ // For embedded fields: filter sub-fields with access restrictions.
47
+ // A list-embedded value is an array of rows — each row is filtered on its
48
+ // own, so a sub-field rule can reference the row it sits in. Filtering the
49
+ // array as if it were one object would turn it into `{0: …, 1: …}`.
47
50
  if (field.type === "embedded" && value && typeof value === "object") {
48
- const filtered: Record<string, unknown> = {};
49
- for (const [subKey, subValue] of Object.entries(value as DbRow)) {
50
- const subField = field.schema[subKey];
51
- const subAccess = normalizeAccessEntry(subField?.access?.read);
52
- if (!userCanReadFieldRow(user, subAccess, value as DbRow)) {
53
- continue;
51
+ const filterRow = (row: DbRow): Record<string, unknown> => {
52
+ const filtered: Record<string, unknown> = {};
53
+ for (const [subKey, subValue] of Object.entries(row)) {
54
+ const subField = field.schema[subKey];
55
+ const subAccess = normalizeAccessEntry(subField?.access?.read);
56
+ if (!userCanReadFieldRow(user, subAccess, row)) continue;
57
+ filtered[subKey] = subValue;
54
58
  }
55
- filtered[subKey] = subValue;
56
- }
57
- result[key] = filtered;
59
+ return filtered;
60
+ };
61
+ result[key] = Array.isArray(value)
62
+ ? value.map((row) => (row && typeof row === "object" ? filterRow(row as DbRow) : row))
63
+ : filterRow(value as DbRow);
58
64
  } else {
59
65
  result[key] = value;
60
66
  }
@@ -108,6 +108,7 @@ export {
108
108
  createDecimalField,
109
109
  createDerivedField,
110
110
  createEmbeddedField,
111
+ createEmbeddedListField,
111
112
  createEntity,
112
113
  createFileField,
113
114
  createFilesField,
@@ -50,8 +50,26 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
50
50
  return z.boolean();
51
51
  case "date":
52
52
  return z.string().date();
53
+ case "money":
54
+ // Signed minor units; the currency lives on the head aggregate, not the
55
+ // row. The safe-integer cap mirrors bigInt mode:"number" — jsonb has no
56
+ // BIGINT column behind it, so 2^53 is the real representability boundary.
57
+ return z.number().int().safe();
58
+ case "decimal": {
59
+ // No numeric column behind jsonb, so the bounds come from float
60
+ // representability alone: the value scaled by 10^scale must be a safe
61
+ // integer, i.e. at most `scale` fractional digits within ±2^53.
62
+ const limit = Number.MAX_SAFE_INTEGER / 10 ** subField.scale;
63
+ return z
64
+ .number()
65
+ .gte(-limit)
66
+ .lte(limit)
67
+ .refine((n) => isRepresentableAtScale(n, subField.scale), {
68
+ message: `at most ${subField.scale} decimal places`,
69
+ });
70
+ }
53
71
  default:
54
- assertUnreachable(subField.type, "embedded sub-field type");
72
+ assertUnreachable(subField, "embedded sub-field type");
55
73
  }
56
74
  }
57
75
 
@@ -173,7 +191,13 @@ export function fieldToZod(
173
191
  const zodSub = embeddedSubFieldToZod(subField);
174
192
  shape[subName] = subField.required ? zodSub : zodSub.optional();
175
193
  }
176
- return z.object(shape);
194
+ const row = z.object(shape);
195
+ if (field.multiple !== true) return row;
196
+ // `required: true` means non-empty, same reading as multiSelect —
197
+ // whether the key may be omitted at all is decided by buildInsertSchema
198
+ // off the same flag.
199
+ const list = z.array(row);
200
+ return field.required === true ? list.min(1) : list;
177
201
  }
178
202
  case "jsonb": {
179
203
  // Free-form jsonb — keys sind tenant-/runtime-defined. Validation
@@ -223,7 +223,15 @@ export async function buildSearchDocument(
223
223
  if (embeddedFields.has(parentKey)) {
224
224
  const subKey = f.slice(underscoreIdx + 1);
225
225
  const parent = state[parentKey];
226
- if (parent && typeof parent === "object") {
226
+ // A list-embedded parent contributes one indexed value per row —
227
+ // Meilisearch indexes a string array as a searchable multi-value.
228
+ if (Array.isArray(parent)) {
229
+ const values = parent
230
+ .filter((row): row is DbRow => Boolean(row) && typeof row === "object")
231
+ .map((row) => row[subKey])
232
+ .filter((value) => value !== undefined);
233
+ if (values.length > 0) fields[f] = values;
234
+ } else if (parent && typeof parent === "object") {
227
235
  const value = (parent as DbRow)[subKey];
228
236
  if (value !== undefined) fields[f] = value;
229
237
  }
@@ -460,13 +460,15 @@ function fieldToFixture(name: string, field: FieldDefinition): unknown {
460
460
  sub[subName] =
461
461
  subDef.type === "text"
462
462
  ? `e2e ${subName}`
463
- : subDef.type === "number"
463
+ : subDef.type === "number" || subDef.type === "decimal"
464
464
  ? 1
465
- : subDef.type === "boolean"
466
- ? true
467
- : "2026-01-01";
465
+ : subDef.type === "money"
466
+ ? 100
467
+ : subDef.type === "boolean"
468
+ ? true
469
+ : "2026-01-01";
468
470
  }
469
- return sub;
471
+ return field.multiple === true ? [sub] : sub;
470
472
  }
471
473
  case "jsonb":
472
474
  // Free-form jsonb — e2e-generator returns empty-object.