@cosmicdrift/kumiko-framework 0.180.0 → 0.182.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 +3 -3
- package/src/bun-db/query.ts +33 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +33 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +12 -0
- package/src/db/entity-table-meta.ts +8 -1
- package/src/db/event-store-executor-write.ts +42 -32
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +31 -21
- package/src/engine/__tests__/content-collection.test.ts +93 -0
- package/src/engine/__tests__/engine.test.ts +27 -2
- package/src/engine/__tests__/entity-handlers.test.ts +16 -0
- package/src/engine/__tests__/field-access.test.ts +27 -0
- package/src/engine/__tests__/schema-builder.test.ts +103 -0
- package/src/engine/__tests__/search-payload-extension.test.ts +46 -0
- package/src/engine/boot-validator/entity-handler.ts +12 -1
- package/src/engine/entity-handlers.ts +2 -2
- package/src/engine/factories.ts +21 -0
- package/src/engine/field-access.ts +16 -10
- package/src/engine/index.ts +2 -0
- package/src/engine/schema-builder.ts +26 -2
- package/src/pipeline/system-hooks.ts +9 -1
- package/src/testing/e2e-generator.ts +7 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.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.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.182.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.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.182.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
package/src/bun-db/query.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
import { Temporal } from "temporal-polyfill";
|
|
31
31
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
32
32
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
33
|
+
import { extractPgError } from "../db/pg-error";
|
|
33
34
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
34
35
|
import { camelCase as envCamelCase } from "../env";
|
|
35
36
|
import { parseJsonSafe } from "../utils/safe-json";
|
|
@@ -135,6 +136,38 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
|
|
|
135
136
|
return raw.savepoint(fn);
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// Same error-confinement as runInSavepoint, but for call sites that don't
|
|
140
|
+
// know whether `db` is a bare pool connection or an active transaction
|
|
141
|
+
// (e.g. the CRUD executor, invoked both from a dispatcher tx and directly
|
|
142
|
+
// against the pool by seeds/tests). A pool connection has no savepoint() —
|
|
143
|
+
// and doesn't need one, since each statement there is its own auto-committed
|
|
144
|
+
// unit and a failed statement can't poison anything downstream.
|
|
145
|
+
//
|
|
146
|
+
// `.savepoint` being present isn't proof the transaction is still open: an
|
|
147
|
+
// afterCommit hook closure captures the same handlerContext (and therefore
|
|
148
|
+
// the same TransactionSql-shaped db) that was live during the write, but by
|
|
149
|
+
// the time the hook fires the outer tx has already committed — the object
|
|
150
|
+
// still exposes `.savepoint`, calling it now fails with PG 25P01 ("no
|
|
151
|
+
// active sql transaction") because there's no BEGIN left to nest into. That
|
|
152
|
+
// SAVEPOINT command is the first thing the driver sends, before `fn` runs,
|
|
153
|
+
// so catching 25P01 and retrying directly is safe — nothing from `fn` has
|
|
154
|
+
// executed yet.
|
|
155
|
+
export async function runInSavepointIfSupported<T>(
|
|
156
|
+
db: unknown,
|
|
157
|
+
fn: (sp: unknown) => Promise<T>,
|
|
158
|
+
): Promise<T> {
|
|
159
|
+
const raw = asRawClient(db) as unknown as {
|
|
160
|
+
savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
|
|
161
|
+
};
|
|
162
|
+
if (typeof raw.savepoint !== "function") return fn(db);
|
|
163
|
+
try {
|
|
164
|
+
return await raw.savepoint(fn);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (extractPgError(e)?.code === "25P01") return fn(db);
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
/**
|
|
139
172
|
* When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
|
|
140
173
|
* unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// verb entirely, and restore()'s two precondition failures.
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { asRawClient } from "../../db/query";
|
|
8
|
+
import { asRawClient, transaction } from "../../db/query";
|
|
9
9
|
import { createEntity, createTextField } from "../../engine";
|
|
10
10
|
import { from } from "../../engine/ownership";
|
|
11
11
|
import { createEventsTable } from "../../event-store";
|
|
@@ -419,6 +419,38 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
|
|
|
419
419
|
expect(healthCheck[0]?.ok).toBe(1);
|
|
420
420
|
});
|
|
421
421
|
|
|
422
|
+
// kumiko-framework#1778 — a real write handler runs create() inside the
|
|
423
|
+
// dispatcher's transaction (sql.begin()), not on the bare pool like the
|
|
424
|
+
// race test above. postgres.js/Bun.SQL poison the WHOLE begin() block
|
|
425
|
+
// once any statement inside it errors, even if the JS layer already
|
|
426
|
+
// caught and classified that error — so without the runInSavepoint fix
|
|
427
|
+
// in event-store-executor-write.ts, the LOSER's transaction() call itself
|
|
428
|
+
// rejects with the raw PostgresError instead of resolving to the
|
|
429
|
+
// version_conflict writeFailure create() already produced.
|
|
430
|
+
test("two concurrent first-time creates of the same id inside a transaction → one succeeds, one converges to version_conflict", async () => {
|
|
431
|
+
const id = "11111111-1111-4111-8111-111111111111";
|
|
432
|
+
|
|
433
|
+
const [a, b] = await Promise.all([
|
|
434
|
+
transaction(testDb.db, (tx) =>
|
|
435
|
+
crud.create({ id, email: "a@test.de" }, admin, createTenantDb(tx, admin.tenantId)),
|
|
436
|
+
),
|
|
437
|
+
transaction(testDb.db, (tx) =>
|
|
438
|
+
crud.create({ id, email: "b@test.de" }, admin, createTenantDb(tx, admin.tenantId)),
|
|
439
|
+
),
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
const results = [a, b];
|
|
443
|
+
expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
|
|
444
|
+
expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
|
|
445
|
+
1,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
|
|
449
|
+
ok: number;
|
|
450
|
+
}>;
|
|
451
|
+
expect(healthCheck[0]?.ok).toBe(1);
|
|
452
|
+
});
|
|
453
|
+
|
|
422
454
|
test("forget with entityCache clears the cache entry", async () => {
|
|
423
455
|
const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
|
|
424
456
|
if (!created.isSuccess) throw new Error("setup failed");
|
|
@@ -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 [
|
|
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":
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { generateId } from "../utils";
|
|
19
19
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
20
20
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
21
|
-
import type { DbRow } from "./connection";
|
|
21
|
+
import type { DbRow, DbRunner } from "./connection";
|
|
22
22
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
23
23
|
import {
|
|
24
24
|
buildEventMetadata,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
entityEventName,
|
|
27
27
|
tryMapUniqueViolation,
|
|
28
28
|
} from "./event-store-executor-context";
|
|
29
|
-
import { selectMany } from "./query";
|
|
29
|
+
import { runInSavepointIfSupported, selectMany } from "./query";
|
|
30
30
|
|
|
31
31
|
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
32
32
|
// store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
|
|
@@ -190,30 +190,34 @@ export function createWriteVerbs(
|
|
|
190
190
|
// selben catch (siehe line 493+).
|
|
191
191
|
let event: Awaited<ReturnType<typeof append>>;
|
|
192
192
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
// Savepoint-scoped: postgres.js/Bun.SQL poison the WHOLE surrounding
|
|
194
|
+
// begin() once any statement inside it errors, even if the JS error
|
|
195
|
+
// is caught (kumiko-framework#1778) — a losing concurrent create's
|
|
196
|
+
// unique-violation would otherwise abort the caller's outer
|
|
197
|
+
// transaction and surface as internal_error at commit time instead
|
|
198
|
+
// of the version_conflict this catch classifies. runInSavepointIfSupported
|
|
199
|
+
// confines the failed INSERT to a nested scope that rolls back on
|
|
200
|
+
// its own (same pattern as ctx.tryAppendEvent), and falls back to a
|
|
201
|
+
// plain call when db.raw is a bare pool connection with no active
|
|
202
|
+
// transaction to poison (seeds/tests calling the executor directly).
|
|
203
|
+
event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
204
|
+
append(sp as DbRunner, {
|
|
205
|
+
aggregateId,
|
|
206
|
+
aggregateType: entityName,
|
|
207
|
+
tenantId: streamTenantFor(user),
|
|
208
|
+
expectedVersion: 0,
|
|
209
|
+
type: entityEventName(entityName, "created"),
|
|
210
|
+
payload: flatData,
|
|
211
|
+
metadata: buildEventMetadata(user),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
202
214
|
} catch (e) {
|
|
203
215
|
if (e instanceof EventStoreVersionConflict) {
|
|
204
|
-
// Try to look up the real stream-version for the diagnostic — but
|
|
205
|
-
// wrap defensively: when `append` raised the unique-violation, the
|
|
206
|
-
// current TX is already aborted, and a second query on the same
|
|
207
|
-
// runner would re-throw "current transaction is aborted". Update-
|
|
208
|
-
// path doesn't have this problem (it queries getStreamVersion
|
|
209
|
-
// BEFORE the try-block). Falling back to a sentinel keeps the
|
|
210
|
-
// version_conflict mapping reliable; the actual current version
|
|
211
|
-
// is recoverable client-side via a fresh detail-query if needed.
|
|
212
216
|
let currentVersion = -1;
|
|
213
217
|
try {
|
|
214
218
|
currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
|
|
215
219
|
} catch {
|
|
216
|
-
//
|
|
220
|
+
// Lookup failure — keep the sentinel.
|
|
217
221
|
}
|
|
218
222
|
return writeFailure(
|
|
219
223
|
new FrameworkVersionConflict({
|
|
@@ -412,18 +416,24 @@ export function createWriteVerbs(
|
|
|
412
416
|
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
413
417
|
// fields doesn't land in the immutable log (flatChanges is already
|
|
414
418
|
// ciphertext from encryptForStorage above).
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
419
|
+
const encryptedPrevious = await encryptForStorage(previous, user);
|
|
420
|
+
// Savepoint-scoped — see the create() append() above for why:
|
|
421
|
+
// confines a losing writer's unique-violation to a nested scope
|
|
422
|
+
// instead of poisoning the whole outer transaction.
|
|
423
|
+
const event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
424
|
+
append(sp as DbRunner, {
|
|
425
|
+
aggregateId: String(payload.id),
|
|
426
|
+
aggregateType: entityName,
|
|
427
|
+
tenantId: streamTenantFor(user),
|
|
428
|
+
expectedVersion: currentVersion,
|
|
429
|
+
type: entityEventName(entityName, "updated"),
|
|
430
|
+
payload: {
|
|
431
|
+
changes: flatChanges,
|
|
432
|
+
previous: encryptedPrevious,
|
|
433
|
+
},
|
|
434
|
+
metadata: buildEventMetadata(user),
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
427
437
|
|
|
428
438
|
// Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
|
|
429
439
|
// apply liest nur `changes`, und die sind live wie im Replay
|
package/src/db/query.ts
CHANGED
package/src/db/table-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
337
|
-
|
|
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<
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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,
|
|
@@ -115,6 +115,65 @@ describe("r.contentCollection() — registration", () => {
|
|
|
115
115
|
expect(feature.contentCollections?.["signatures"]?.ownership).toBe("user");
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
test("records contentFormat so the client can resolve the right editor", () => {
|
|
119
|
+
const feature = defineFeature("mail", (r) => {
|
|
120
|
+
r.contentCollection({
|
|
121
|
+
id: "prompts",
|
|
122
|
+
kind: "ai-prompt",
|
|
123
|
+
contentFormat: "plain",
|
|
124
|
+
nav: { label: "mail:nav.prompts" },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(feature.contentCollections?.["prompts"]?.contentFormat).toBe("plain");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("contentFormat is optional — undefined when the app didn't declare one", () => {
|
|
132
|
+
const feature = defineFeature("mail", (r) => {
|
|
133
|
+
r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(feature.contentCollections?.["templates"]?.contentFormat).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("records contentFormat markdown as a third format alongside plain/rich", () => {
|
|
140
|
+
const feature = defineFeature("mail", (r) => {
|
|
141
|
+
r.contentCollection({
|
|
142
|
+
id: "notes",
|
|
143
|
+
kind: "text-block",
|
|
144
|
+
contentFormat: "markdown",
|
|
145
|
+
nav: { label: "mail:nav.notes" },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(feature.contentCollections?.["notes"]?.contentFormat).toBe("markdown");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("records variableSchema so the client can offer the right chips", () => {
|
|
153
|
+
const feature = defineFeature("mail", (r) => {
|
|
154
|
+
r.contentCollection({
|
|
155
|
+
id: "prompts",
|
|
156
|
+
kind: "ai-prompt",
|
|
157
|
+
contentFormat: "plain",
|
|
158
|
+
variableSchema: { customerName: {}, orderId: {} },
|
|
159
|
+
nav: { label: "mail:nav.prompts" },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(feature.contentCollections?.["prompts"]?.variableSchema).toEqual({
|
|
164
|
+
customerName: {},
|
|
165
|
+
orderId: {},
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("variableSchema is optional — undefined when the app didn't declare one", () => {
|
|
170
|
+
const feature = defineFeature("mail", (r) => {
|
|
171
|
+
r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(feature.contentCollections?.["templates"]?.variableSchema).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
118
177
|
test("rejects a second collection with the same id", () => {
|
|
119
178
|
expect(() =>
|
|
120
179
|
defineFeature("mail", (r) => {
|
|
@@ -179,6 +238,7 @@ describe("buildAppSchema — content collections", () => {
|
|
|
179
238
|
r.contentCollection({
|
|
180
239
|
id: "templates",
|
|
181
240
|
kind: "mail-html",
|
|
241
|
+
contentFormat: "rich",
|
|
182
242
|
nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
|
|
183
243
|
});
|
|
184
244
|
}),
|
|
@@ -190,6 +250,7 @@ describe("buildAppSchema — content collections", () => {
|
|
|
190
250
|
{
|
|
191
251
|
id: "templates",
|
|
192
252
|
kind: "mail-html",
|
|
253
|
+
contentFormat: "rich",
|
|
193
254
|
nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
|
|
194
255
|
navQn: "mail:nav:templates",
|
|
195
256
|
},
|
|
@@ -199,6 +260,38 @@ describe("buildAppSchema — content collections", () => {
|
|
|
199
260
|
expect(mail?.navs?.map((n) => n.id)).toContain("templates");
|
|
200
261
|
});
|
|
201
262
|
|
|
263
|
+
test("projects variableSchema through to the client schema", () => {
|
|
264
|
+
const registry = createRegistry([
|
|
265
|
+
defineFeature("mail", (r) => {
|
|
266
|
+
r.contentCollection({
|
|
267
|
+
id: "prompts",
|
|
268
|
+
kind: "ai-prompt",
|
|
269
|
+
contentFormat: "plain",
|
|
270
|
+
variableSchema: { customerName: {} },
|
|
271
|
+
nav: { label: "mail:nav.prompts" },
|
|
272
|
+
});
|
|
273
|
+
}),
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const schema = buildAppSchema(registry);
|
|
277
|
+
const mail = schema.features.find((f) => f.featureName === "mail");
|
|
278
|
+
expect(mail?.contentCollections?.[0]?.variableSchema).toEqual({ customerName: {} });
|
|
279
|
+
// buildAppSchema's JSON-safety check throws on undefined leaves — proves
|
|
280
|
+
// the new field survives that check instead of only the toEqual above.
|
|
281
|
+
expect(() =>
|
|
282
|
+
validateBoot([
|
|
283
|
+
defineFeature("mail2", (r) => {
|
|
284
|
+
r.contentCollection({
|
|
285
|
+
id: "prompts",
|
|
286
|
+
kind: "ai-prompt",
|
|
287
|
+
variableSchema: { customerName: {} },
|
|
288
|
+
nav: { label: "mail2:nav.prompts" },
|
|
289
|
+
});
|
|
290
|
+
}),
|
|
291
|
+
]),
|
|
292
|
+
).not.toThrow();
|
|
293
|
+
});
|
|
294
|
+
|
|
202
295
|
test("omits the slot for features without collections", () => {
|
|
203
296
|
const registry = createRegistry([
|
|
204
297
|
defineFeature("shop", (r) => {
|
|
@@ -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: "
|
|
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 "
|
|
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
|
}),
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
registerEntityCrud,
|
|
15
15
|
} from "../entity-handlers";
|
|
16
16
|
import { createEntity, createTextField } from "../factories";
|
|
17
|
+
// Barrel import, not "../entity-handlers": covers that entityListSchema is
|
|
18
|
+
// actually re-exported through engine/index.ts.
|
|
19
|
+
import { entityListSchema } from "../index";
|
|
17
20
|
import type { QueryHandlerDef, WriteHandlerDef } from "../types";
|
|
18
21
|
|
|
19
22
|
const VALID_UUID = "00000000-0000-4000-8000-000000000001";
|
|
@@ -119,6 +122,19 @@ describe("defineEntityQueryHandler", () => {
|
|
|
119
122
|
expect(def.schema.safeParse({ sortDirection: "wrong" }).success).toBe(false);
|
|
120
123
|
});
|
|
121
124
|
|
|
125
|
+
test("list: schema is the exported entityListSchema, not a private copy", () => {
|
|
126
|
+
const def = defineEntityListHandler("note", noteEntity);
|
|
127
|
+
expect(def.schema).toBe(entityListSchema);
|
|
128
|
+
// money-horse#293: a consumer copy silently dropped these fields when it
|
|
129
|
+
// drifted from the handler's actual schema — assert they still round-trip.
|
|
130
|
+
expect(
|
|
131
|
+
def.schema.safeParse({
|
|
132
|
+
includeDeleted: true,
|
|
133
|
+
filters: [{ field: "title", op: "eq", value: "x" }],
|
|
134
|
+
}).success,
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
122
138
|
test("detail: schema requires id", () => {
|
|
123
139
|
const def = defineEntityDetailHandler("note", noteEntity);
|
|
124
140
|
expect(def.schema.safeParse({ id: VALID_UUID }).success).toBe(true);
|
|
@@ -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
|
}
|
|
@@ -102,7 +102,7 @@ type ListPayload = {
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
const idSchema = z.object({ id: z.uuid() });
|
|
105
|
-
const
|
|
105
|
+
export const entityListSchema = z.object({
|
|
106
106
|
cursor: z.string().optional(),
|
|
107
107
|
limit: z.number().optional(),
|
|
108
108
|
search: z.string().optional(),
|
|
@@ -292,7 +292,7 @@ export function defineEntityQueryHandler(
|
|
|
292
292
|
|
|
293
293
|
switch (verb) {
|
|
294
294
|
case "list":
|
|
295
|
-
schema =
|
|
295
|
+
schema = entityListSchema;
|
|
296
296
|
handler = async (query, ctx) => {
|
|
297
297
|
// Tier 2.7e Audit-Fix: SearchAdapter aus ctx durchreichen,
|
|
298
298
|
// damit payload.search zur Laufzeit gegen Meilisearch/InMem
|
package/src/engine/factories.ts
CHANGED
|
@@ -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
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
56
|
-
}
|
|
57
|
-
result[key] =
|
|
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
|
}
|
package/src/engine/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export {
|
|
|
69
69
|
defineEntityWriteHandler,
|
|
70
70
|
defineProjectionQueryHandler,
|
|
71
71
|
type EntityCrudRegistrar,
|
|
72
|
+
entityListSchema,
|
|
72
73
|
type RegisterEntityCrudOptions,
|
|
73
74
|
registerEntityCrud,
|
|
74
75
|
} from "./entity-handlers";
|
|
@@ -108,6 +109,7 @@ export {
|
|
|
108
109
|
createDecimalField,
|
|
109
110
|
createDerivedField,
|
|
110
111
|
createEmbeddedField,
|
|
112
|
+
createEmbeddedListField,
|
|
111
113
|
createEntity,
|
|
112
114
|
createFileField,
|
|
113
115
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
466
|
-
?
|
|
467
|
-
: "
|
|
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.
|