@cosmicdrift/kumiko-framework 0.36.0 → 0.38.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 +2 -2
- package/src/api/routes.ts +2 -7
- package/src/api/server.ts +1 -1
- package/src/bun-db/connection.ts +1 -0
- package/src/bun-db/index.ts +0 -1
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +42 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +34 -0
- package/src/db/collect-table-metas.ts +2 -2
- package/src/db/connection.ts +1 -0
- package/src/db/dialect.ts +16 -3
- package/src/db/event-store-executor.ts +29 -0
- package/src/db/index.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/tenant-db.ts +14 -4
- package/src/engine/__tests__/build-app-schema.test.ts +31 -3
- package/src/engine/__tests__/engine.test.ts +3 -3
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +8 -8
- package/src/engine/__tests__/post-query-hook.test.ts +3 -3
- package/src/engine/__tests__/validation-hooks.test.ts +2 -2
- package/src/engine/boot-validator/entity-handler.ts +14 -11
- package/src/engine/boot-validator/ownership.ts +1 -1
- package/src/engine/boot-validator/pii-retention.ts +1 -1
- package/src/engine/boot-validator/screens-nav.ts +9 -6
- package/src/engine/build-app-schema.ts +42 -12
- package/src/engine/create-app.ts +1 -1
- package/src/engine/define-feature.ts +5 -1
- package/src/engine/feature-ast/extractors/round4.ts +1 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/registry.ts +4 -3
- package/src/engine/steps/unsafe-projection-upsert.ts +4 -15
- package/src/engine/types/feature.ts +7 -3
- package/src/engine/types/hooks.ts +15 -11
- package/src/engine/types/screen.ts +2 -7
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/engine/validation.ts +1 -1
- package/src/errors/index.ts +1 -1
- package/src/errors/to-kumiko-error.ts +8 -0
- package/src/event-store/archive.ts +1 -0
- package/src/event-store/event-store.ts +1 -16
- package/src/event-store/index.ts +1 -0
- package/src/event-store/row-to-stored-event.ts +34 -0
- package/src/logging/__tests__/fallback-logger.test.ts +5 -5
- package/src/logging/utils.ts +1 -1
- package/src/migrations/projection-table-index.ts +3 -15
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +75 -0
- package/src/pipeline/dispatcher-utils.ts +2 -12
- package/src/pipeline/event-consumer-state.ts +1 -0
- package/src/pipeline/event-dispatcher.ts +21 -42
- package/src/pipeline/msp-rebuild.ts +8 -19
- package/src/pipeline/projection-rebuild.ts +2 -13
- package/src/pipeline/system-hooks.ts +17 -4
- package/src/random/words.ts +4 -3
- package/src/stack/test-stack.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.37.0",
|
|
185
185
|
"@types/uuid": "^11.0.0",
|
|
186
186
|
"bun-types": "^1.3.13",
|
|
187
187
|
"pino-pretty": "^13.1.3"
|
package/src/api/routes.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
2
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
3
|
import {
|
|
4
|
-
InternalError,
|
|
5
|
-
isKumikoError,
|
|
6
4
|
type KumikoError,
|
|
7
5
|
reraiseAsKumikoError,
|
|
8
6
|
serializeError,
|
|
7
|
+
toKumikoError,
|
|
9
8
|
ValidationError,
|
|
10
9
|
} from "../errors";
|
|
11
10
|
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
@@ -115,11 +114,7 @@ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode =
|
|
|
115
114
|
return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
if (isKumikoError(e)) return e;
|
|
120
|
-
if (e instanceof Error) return new InternalError({ cause: e });
|
|
121
|
-
return new InternalError({ message: String(e) });
|
|
122
|
-
}
|
|
117
|
+
const toKumiko = toKumikoError;
|
|
123
118
|
|
|
124
119
|
// For /write + /batch: keep the isSuccess flag so clients can flip on a single
|
|
125
120
|
// boolean (mirrors the success shape). The actual error body is the
|
package/src/api/server.ts
CHANGED
|
@@ -635,7 +635,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
635
635
|
// the yes/no answer for the boot check.
|
|
636
636
|
function registryDeclaresFileFields(registry: Registry): boolean {
|
|
637
637
|
for (const feature of registry.features.values()) {
|
|
638
|
-
for (const entity of Object.values(feature.entities)) {
|
|
638
|
+
for (const entity of Object.values(feature.entities ?? {})) {
|
|
639
639
|
for (const field of Object.values(entity.fields)) {
|
|
640
640
|
if (isFileField(field)) return true;
|
|
641
641
|
}
|
package/src/bun-db/connection.ts
CHANGED
|
@@ -66,6 +66,7 @@ export function createBunDbConnection(
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// guard:dup-ok — ENV-Reader für BunDB (gibt { db, listenClient } zurück), nicht verwandt mit redisClientOptionsFromEnv
|
|
69
70
|
export function bunDbConnectionOptionsFromEnv(
|
|
70
71
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
71
72
|
): BunDbConnectionOptions {
|
package/src/bun-db/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ export type {
|
|
|
11
11
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection";
|
|
12
12
|
export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query";
|
|
13
13
|
export {
|
|
14
|
-
asEntityTableMeta,
|
|
15
14
|
asRawClient,
|
|
16
15
|
countWhere,
|
|
17
16
|
type DeleteManyBatchedOptions,
|
|
@@ -40,7 +40,7 @@ afterAll(async () => {
|
|
|
40
40
|
async function applySchema(features: readonly FeatureDefinition[]): Promise<void> {
|
|
41
41
|
const tables: Record<string, unknown> = {};
|
|
42
42
|
for (const feature of features) {
|
|
43
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
43
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
44
44
|
tables[entityName] = buildEntityTable(entityName, entity);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { asEntityTableMeta } from "../../bun-db/query";
|
|
3
2
|
import { createEntity } from "../../engine/factories";
|
|
3
|
+
import { sql } from "../dialect";
|
|
4
4
|
import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
|
|
5
5
|
import { buildEntityTableMeta } from "../entity-table-meta";
|
|
6
|
+
import { asEntityTableMeta } from "../query";
|
|
6
7
|
import { buildEntityTable } from "../table-builder";
|
|
7
8
|
|
|
8
9
|
// Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
|
|
@@ -60,3 +61,43 @@ describe("buildEntityTable ↔ buildEntityTableMeta lock-step", () => {
|
|
|
60
61
|
expect(cols.get("active")?.defaultSql).toBe("true");
|
|
61
62
|
});
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
// Zweite Probe: softDelete + explizite Indexes (unique, partial, multi-col).
|
|
66
|
+
// Diese Pfade generieren zusätzliche Spalten (deleted_at/_by) bzw. Index-
|
|
67
|
+
// Metas — Drift hier blieb von der defaults-Probe oben unentdeckt.
|
|
68
|
+
const entityWithSoftDeleteAndIndexes = createEntity({
|
|
69
|
+
table: "read_lockstep_probe_sd",
|
|
70
|
+
fields: {
|
|
71
|
+
title: { type: "text", required: true },
|
|
72
|
+
ownerId: { type: "text", required: true },
|
|
73
|
+
status: { type: "select", options: ["open", "done"], required: true, default: "open" },
|
|
74
|
+
},
|
|
75
|
+
softDelete: true,
|
|
76
|
+
indexes: [
|
|
77
|
+
{ columns: ["ownerId"] },
|
|
78
|
+
{ columns: ["ownerId", "status"], unique: true },
|
|
79
|
+
{ columns: ["title"], unique: true, where: sql`status = 'open'`, name: "open_title_unique" },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("lock-step — softDelete + explizite Indexes", () => {
|
|
84
|
+
const fromBuilder = asEntityTableMeta(
|
|
85
|
+
buildEntityTable("lockstepProbeSd", entityWithSoftDeleteAndIndexes),
|
|
86
|
+
);
|
|
87
|
+
const fromMeta = buildEntityTableMeta("lockstepProbeSd", entityWithSoftDeleteAndIndexes);
|
|
88
|
+
|
|
89
|
+
test("identical columns inkl. softDelete-Spalten", () => {
|
|
90
|
+
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
91
|
+
byName<ColumnMeta>(fromMeta.columns),
|
|
92
|
+
);
|
|
93
|
+
const names = (fromBuilder?.columns ?? []).map((c) => c.name);
|
|
94
|
+
expect(names).toContain("deleted_at");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("identical indexes inkl. unique/partial/multi-col", () => {
|
|
98
|
+
expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
|
|
99
|
+
byName<IndexMeta>(fromMeta.indexes),
|
|
100
|
+
);
|
|
101
|
+
expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -79,3 +79,37 @@ describe("tenant-db WHERE merge — caller cannot override tenant scope", () =>
|
|
|
79
79
|
expect(captured[0]?.values).toContain(own);
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
describe("tenant-db WHERE merge — narrowing within the enforced scope", () => {
|
|
84
|
+
const SYSTEM = "00000000-0000-4000-8000-000000000000";
|
|
85
|
+
|
|
86
|
+
test("where.tenantId = own narrows to own only (excludes SYSTEM reference rows)", async () => {
|
|
87
|
+
const captured: Captured[] = [];
|
|
88
|
+
const tdb = createTenantDb(recordingDb(captured), own);
|
|
89
|
+
|
|
90
|
+
await tdb.selectMany(table, { tenantId: own });
|
|
91
|
+
|
|
92
|
+
expect(captured[0]?.values).toContain(own);
|
|
93
|
+
expect(captured[0]?.values).not.toContain(SYSTEM);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("where.tenantId = [own, SYSTEM] keeps both", async () => {
|
|
97
|
+
const captured: Captured[] = [];
|
|
98
|
+
const tdb = createTenantDb(recordingDb(captured), own);
|
|
99
|
+
|
|
100
|
+
await tdb.selectMany(table, { tenantId: [own, SYSTEM] });
|
|
101
|
+
|
|
102
|
+
expect(captured[0]?.values).toContain(own);
|
|
103
|
+
expect(captured[0]?.values).toContain(SYSTEM);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("mixed [own, foreign] drops the foreign id, keeps own", async () => {
|
|
107
|
+
const captured: Captured[] = [];
|
|
108
|
+
const tdb = createTenantDb(recordingDb(captured), own);
|
|
109
|
+
|
|
110
|
+
await tdb.selectMany(table, { tenantId: [own, foreign] });
|
|
111
|
+
|
|
112
|
+
expect(captured[0]?.values).toContain(own);
|
|
113
|
+
expect(captured[0]?.values).not.toContain(foreign);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// read_subscriptions) nie in Migrations landeten und der erste Prod-Write
|
|
7
7
|
// crashte (#255).
|
|
8
8
|
|
|
9
|
-
import { asEntityTableMeta } from "../bun-db/query";
|
|
10
9
|
import type { FeatureDefinition } from "../engine/types";
|
|
11
10
|
import { buildEntityTableMeta, type EntityTableMeta } from "./entity-table-meta";
|
|
12
11
|
import { enumerateFeatureTableSources } from "./feature-table-sources";
|
|
12
|
+
import { asEntityTableMeta } from "./query";
|
|
13
13
|
|
|
14
14
|
function canonicalColumnsKey(meta: EntityTableMeta): string {
|
|
15
15
|
// Spalten-Identität unabhängig von Deklarations-Reihenfolge und
|
|
@@ -34,7 +34,7 @@ export function collectTableMetas(
|
|
|
34
34
|
// Pass 1: kanonische Schema-Quellen, identisch zum bisherigen Template-
|
|
35
35
|
// Verhalten (gleiche Reihenfolge, gleiche buildEntityTableMeta-Optionen).
|
|
36
36
|
for (const feature of features) {
|
|
37
|
-
for (const [name, ent] of Object.entries(feature.entities)) {
|
|
37
|
+
for (const [name, ent] of Object.entries(feature.entities ?? {})) {
|
|
38
38
|
const meta = buildEntityTableMeta(name, ent, { relations: feature.relations[name] });
|
|
39
39
|
metas.push(meta);
|
|
40
40
|
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
package/src/db/connection.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type PgClient = ReturnType<typeof postgres>;
|
|
|
17
17
|
export type PgListenClient = ReturnType<typeof postgres>;
|
|
18
18
|
|
|
19
19
|
// Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
|
|
20
|
+
// guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
|
|
20
21
|
export function createDbConnection(
|
|
21
22
|
url: string,
|
|
22
23
|
options: import("./api").DbConnectionOptions = {},
|
package/src/db/dialect.ts
CHANGED
|
@@ -272,9 +272,9 @@ export function instant(
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// moneyAmount kept as a customType-style API but produces a bigint column.
|
|
275
|
-
// bigintJsMode "bigint" —
|
|
276
|
-
//
|
|
277
|
-
//
|
|
275
|
+
// bigintJsMode "bigint" — entity-table-meta renders money as bigint, and the
|
|
276
|
+
// table-builder↔meta lockstep guard fails on a number-mode column. (Precision
|
|
277
|
+
// past 2^53 is the underlying motivation, not the immediate breakage.)
|
|
278
278
|
export const moneyAmount = (name: string): ColumnBuilder<number> =>
|
|
279
279
|
buildColumn(name, "bigint", { bigintJsMode: "bigint" }) as ColumnBuilder<number>;
|
|
280
280
|
|
|
@@ -482,6 +482,19 @@ export function table<TCols extends ColumnMap>(
|
|
|
482
482
|
return out;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
/** Reads the `kumiko:schema:Name` symbol from a table object.
|
|
486
|
+
* Throws with `context` in the message so callers don't have to duplicate the guard. */
|
|
487
|
+
export function extractTableName(table: unknown, context = "extractTableName"): string {
|
|
488
|
+
if (typeof table !== "object" || table === null) {
|
|
489
|
+
throw new Error(`${context}: table is not an object`);
|
|
490
|
+
}
|
|
491
|
+
const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
492
|
+
if (typeof name !== "string") {
|
|
493
|
+
throw new Error(`${context}: table missing kumiko:schema:Name symbol`);
|
|
494
|
+
}
|
|
495
|
+
return name;
|
|
496
|
+
}
|
|
497
|
+
|
|
485
498
|
// Helper used by `instantToDriver` callers in legacy code — kept identical
|
|
486
499
|
// to the previous behaviour. The native dialect handles parse/serialize
|
|
487
500
|
// implicitly via the Bun driver; this function is a defensive coerce at
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
FieldDefinition,
|
|
17
17
|
SaveContext,
|
|
18
18
|
SessionUser,
|
|
19
|
+
TenantId,
|
|
19
20
|
WriteResult,
|
|
20
21
|
} from "../engine/types";
|
|
21
22
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
@@ -29,10 +30,12 @@ import {
|
|
|
29
30
|
writeFailure,
|
|
30
31
|
} from "../errors";
|
|
31
32
|
import {
|
|
33
|
+
ArchivedStreamError,
|
|
32
34
|
append,
|
|
33
35
|
type EventMetadata,
|
|
34
36
|
VersionConflictError as EventStoreVersionConflict,
|
|
35
37
|
getStreamVersion,
|
|
38
|
+
isStreamArchived,
|
|
36
39
|
} from "../event-store";
|
|
37
40
|
import type { EntityCache } from "../pipeline/entity-cache";
|
|
38
41
|
import type { SearchAdapter } from "../search/types";
|
|
@@ -291,6 +294,26 @@ export function createEventStoreExecutor(
|
|
|
291
294
|
return rehydrateCompoundTypes(row as DbRow, entity);
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
// Archive guard for the CRUD write paths. Archived streams are read-only —
|
|
298
|
+
// ctx.appendEvent (append-event-core) already enforces this, but the
|
|
299
|
+
// executor appends directly via append() and getStreamVersion() ignores
|
|
300
|
+
// the archive flag, so without this check a PATCH/DELETE on an archived
|
|
301
|
+
// entity would silently land an event and break the read-only contract
|
|
302
|
+
// (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
|
|
303
|
+
// to mirror the appendEvent path exactly — same 500 + rolled-back tx.
|
|
304
|
+
// Creates skip this: a fresh UUID can't be archived, and a deterministic-id
|
|
305
|
+
// re-create onto an archived stream collides on the unique index →
|
|
306
|
+
// version_conflict, which already blocks the write.
|
|
307
|
+
async function assertStreamWritable(
|
|
308
|
+
db: TenantDb,
|
|
309
|
+
id: EntityId,
|
|
310
|
+
tenantId: TenantId,
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
if (await isStreamArchived(db.raw, tenantId, String(id))) {
|
|
313
|
+
throw new ArchivedStreamError(tenantId, String(id));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
294
317
|
// SELECT a row by id with the ownership clause applied at the DB layer.
|
|
295
318
|
// Detail() uses this both on cold path and as a cache-revalidation probe.
|
|
296
319
|
async function loadWithOwnership(
|
|
@@ -521,6 +544,8 @@ export function createEventStoreExecutor(
|
|
|
521
544
|
);
|
|
522
545
|
}
|
|
523
546
|
|
|
547
|
+
await assertStreamWritable(db, payload.id, user.tenantId);
|
|
548
|
+
|
|
524
549
|
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
525
550
|
// can bump the stream between CRUD writes (domain event on the same
|
|
526
551
|
// aggregate); a stale row.version here would make the next CRUD write
|
|
@@ -655,6 +680,8 @@ export function createEventStoreExecutor(
|
|
|
655
680
|
);
|
|
656
681
|
}
|
|
657
682
|
|
|
683
|
+
await assertStreamWritable(db, payload.id, user.tenantId);
|
|
684
|
+
|
|
658
685
|
// Stream-version authoritative (see update() for rationale).
|
|
659
686
|
const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
|
|
660
687
|
|
|
@@ -730,6 +757,8 @@ export function createEventStoreExecutor(
|
|
|
730
757
|
);
|
|
731
758
|
}
|
|
732
759
|
|
|
760
|
+
await assertStreamWritable(db, payload.id, user.tenantId);
|
|
761
|
+
|
|
733
762
|
// Stream-version authoritative (see update() for rationale).
|
|
734
763
|
const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
|
|
735
764
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
package/src/db/index.ts
CHANGED
package/src/db/query.ts
CHANGED
package/src/db/tenant-db.ts
CHANGED
|
@@ -133,12 +133,22 @@ export function createTenantDb(
|
|
|
133
133
|
|
|
134
134
|
// Reads see own-tenant rows + reference data (tenantId === SYSTEM_TENANT_ID).
|
|
135
135
|
// Writes never touch reference rows — those are system-mode only.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
136
|
+
// A caller-supplied `where.tenantId` may only NARROW the enforced scope
|
|
137
|
+
// (e.g. exclude SYSTEM reference rows at the DB instead of post-filtering
|
|
138
|
+
// after a limit). Values outside the scope are dropped; if nothing valid
|
|
139
|
+
// remains, the full enforced scope applies — widening is never possible.
|
|
139
140
|
function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
|
|
140
141
|
if (!hasTenantColumn(table) || mode === "system") return where;
|
|
141
|
-
const
|
|
142
|
+
const allowed = [tenantId, SYSTEM_TENANT_ID];
|
|
143
|
+
const requested = where?.["tenantId"];
|
|
144
|
+
if (requested !== undefined) {
|
|
145
|
+
const requestedList = Array.isArray(requested) ? requested : [requested];
|
|
146
|
+
const narrowed = requestedList.filter(
|
|
147
|
+
(t): t is string => typeof t === "string" && allowed.includes(t),
|
|
148
|
+
);
|
|
149
|
+
return { ...where, tenantId: narrowed.length > 0 ? narrowed : allowed };
|
|
150
|
+
}
|
|
151
|
+
const tenantFilter: WhereObject = { tenantId: allowed };
|
|
142
152
|
return where ? { ...where, ...tenantFilter } : tenantFilter;
|
|
143
153
|
}
|
|
144
154
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// im Browser-Bundle.
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import { buildAppSchema } from "../build-app-schema";
|
|
11
|
+
import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
|
|
12
12
|
import { defineFeature } from "../define-feature";
|
|
13
13
|
import { createRegistry } from "../registry";
|
|
14
14
|
import type { EntityDefinition } from "../types/fields";
|
|
@@ -207,8 +207,9 @@ describe("buildAppSchema", () => {
|
|
|
207
207
|
const app = buildAppSchema(createRegistry([f]));
|
|
208
208
|
const roundTripped = JSON.parse(JSON.stringify(app));
|
|
209
209
|
|
|
210
|
-
//
|
|
211
|
-
|
|
210
|
+
// toStrictEqual: toEqual ignoriert undefined-Props und würde einen
|
|
211
|
+
// Silent-Drop durch JSON.stringify genau NICHT fangen.
|
|
212
|
+
expect(roundTripped).toStrictEqual(app);
|
|
212
213
|
|
|
213
214
|
// Explizit: FormatSpec-Felder landen unverändert an
|
|
214
215
|
const screen = roundTripped.features[0]?.screens[0];
|
|
@@ -234,3 +235,30 @@ describe("buildAppSchema", () => {
|
|
|
234
235
|
expect(actions?.find((a) => a.id === "always")?.visible).toBe(true);
|
|
235
236
|
});
|
|
236
237
|
});
|
|
238
|
+
|
|
239
|
+
describe("findNonJsonSafePath", () => {
|
|
240
|
+
test("findet eine Funktion ausserhalb von PlatformComponent-Slots mit Pfad", () => {
|
|
241
|
+
const schema = { features: [{ label: () => "nope" }] };
|
|
242
|
+
expect(findNonJsonSafePath(schema, "schema")).toBe("schema.features[0].label");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("PlatformComponent-Slots ({ react, native }) sind opak — Komponenten-Funktionen erlaubt", () => {
|
|
246
|
+
const schema = {
|
|
247
|
+
features: [{ screens: [{ id: "s1", component: { react: () => null } }] }],
|
|
248
|
+
};
|
|
249
|
+
expect(findNonJsonSafePath(schema, "schema")).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("faengt undefined, bigint und Klassen-Instanzen", () => {
|
|
253
|
+
expect(findNonJsonSafePath({ a: undefined }, "schema")).toBe("schema.a");
|
|
254
|
+
expect(findNonJsonSafePath({ a: 1n }, "schema")).toBe("schema.a");
|
|
255
|
+
expect(findNonJsonSafePath({ a: new Map() }, "schema")).toBe("schema.a");
|
|
256
|
+
expect(findNonJsonSafePath({ a: Number.NaN }, "schema")).toBe("schema.a");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("normales JSON-Schema passiert ohne Befund", () => {
|
|
260
|
+
expect(
|
|
261
|
+
findNonJsonSafePath({ features: [{ name: "x", count: 3, on: true, opt: null }] }, "schema"),
|
|
262
|
+
).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -65,9 +65,9 @@ describe("defineFeature", () => {
|
|
|
65
65
|
);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
expect(feature.entities["user"]).toBeDefined();
|
|
69
|
-
expect(feature.entities["user"]?.table).toBe("Users");
|
|
70
|
-
expect(feature.entities["user"]?.fields["email"]?.type).toBe("text");
|
|
68
|
+
expect(feature.entities?.["user"]).toBeDefined();
|
|
69
|
+
expect(feature.entities?.["user"]?.table).toBe("Users");
|
|
70
|
+
expect(feature.entities?.["user"]?.fields["email"]?.type).toBe("text");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
test("collects write handlers with inferred types", () => {
|
|
@@ -22,7 +22,7 @@ describe("HookPhases defaults", () => {
|
|
|
22
22
|
r.hook("postSave", "thing:create", noopSave);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
const entry = feature.hooks
|
|
25
|
+
const entry = feature.hooks?.postSave?.["thing:create"];
|
|
26
26
|
expect(entry).toHaveLength(1);
|
|
27
27
|
expect(entry?.[0]?.phase).toBe(HookPhases.afterCommit);
|
|
28
28
|
});
|
|
@@ -36,7 +36,7 @@ describe("HookPhases defaults", () => {
|
|
|
36
36
|
r.hook("postSave", "thing:create", noopSave, { phase: HookPhases.inTransaction });
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
const entry = feature.hooks
|
|
39
|
+
const entry = feature.hooks?.postSave?.["thing:create"];
|
|
40
40
|
expect(entry?.[0]?.phase).toBe(HookPhases.inTransaction);
|
|
41
41
|
});
|
|
42
42
|
|
|
@@ -55,7 +55,7 @@ describe("HookPhases defaults", () => {
|
|
|
55
55
|
r.hook("preDelete", "thing:delete", async () => undefined);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
const entry = feature.hooks
|
|
58
|
+
const entry = feature.hooks?.preDelete?.["thing:delete"];
|
|
59
59
|
expect(entry?.[0]?.phase).toBe(HookPhases.inTransaction);
|
|
60
60
|
});
|
|
61
61
|
|
|
@@ -66,8 +66,8 @@ describe("HookPhases defaults", () => {
|
|
|
66
66
|
r.entityHook("preDelete", thing, async () => undefined);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
expect(feature.entityHooks
|
|
70
|
-
expect(feature.entityHooks
|
|
69
|
+
expect(feature.entityHooks?.postSave?.["thing"]?.[0]?.phase).toBe(HookPhases.afterCommit);
|
|
70
|
+
expect(feature.entityHooks?.preDelete?.["thing"]?.[0]?.phase).toBe(HookPhases.inTransaction);
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
@@ -15,7 +15,7 @@ describe("lifecycle hook registration", () => {
|
|
|
15
15
|
});
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
expect(Object.keys(feature.hooks
|
|
18
|
+
expect(Object.keys(feature.hooks?.preSave ?? {})).toContain("user");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
test("postSave hooks are registered", () => {
|
|
@@ -23,7 +23,7 @@ describe("lifecycle hook registration", () => {
|
|
|
23
23
|
r.hook("postSave", "user", async () => {});
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
expect(Object.keys(feature.hooks
|
|
26
|
+
expect(Object.keys(feature.hooks?.postSave ?? {})).toContain("user");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
test("preDelete hooks are registered", () => {
|
|
@@ -31,7 +31,7 @@ describe("lifecycle hook registration", () => {
|
|
|
31
31
|
r.hook("preDelete", "user", async () => {});
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
expect(Object.keys(feature.hooks
|
|
34
|
+
expect(Object.keys(feature.hooks?.preDelete ?? {})).toContain("user");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test("postDelete hooks are registered", () => {
|
|
@@ -39,7 +39,7 @@ describe("lifecycle hook registration", () => {
|
|
|
39
39
|
r.hook("postDelete", "user", async () => {});
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
expect(Object.keys(feature.hooks
|
|
42
|
+
expect(Object.keys(feature.hooks?.postDelete ?? {})).toContain("user");
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("multiple hooks on same entity are collected in order", () => {
|
|
@@ -48,7 +48,7 @@ describe("lifecycle hook registration", () => {
|
|
|
48
48
|
r.hook("preSave", "user", async (changes) => changes);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
const hooks = feature.hooks
|
|
51
|
+
const hooks = feature.hooks?.preSave?.["user"];
|
|
52
52
|
expect(hooks).toHaveLength(2);
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -59,9 +59,9 @@ describe("lifecycle hook registration", () => {
|
|
|
59
59
|
r.hook("postSave", "user", async () => {});
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
expect(feature.hooks
|
|
63
|
-
expect(feature.hooks
|
|
64
|
-
expect(feature.hooks
|
|
62
|
+
expect(feature.hooks?.validation?.["userForm"]).toBeDefined();
|
|
63
|
+
expect(feature.hooks?.preSave?.["user"]).toHaveLength(1);
|
|
64
|
+
expect(feature.hooks?.postSave?.["user"]).toHaveLength(1);
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
@@ -30,7 +30,7 @@ describe("postQuery hook registration", () => {
|
|
|
30
30
|
|
|
31
31
|
// feature.hooks.postQuery is keyed by raw handler-name (qualification
|
|
32
32
|
// happens at registry-merge time).
|
|
33
|
-
const entry = feature.hooks
|
|
33
|
+
const entry = feature.hooks?.postQuery?.["thing:list"];
|
|
34
34
|
expect(entry).toHaveLength(1);
|
|
35
35
|
expect(entry?.[0]?.featureName).toBe("test");
|
|
36
36
|
});
|
|
@@ -41,7 +41,7 @@ describe("postQuery hook registration", () => {
|
|
|
41
41
|
r.entityHook("postQuery", thing, noop);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
const entry = feature.entityHooks
|
|
44
|
+
const entry = feature.entityHooks?.postQuery?.["thing"];
|
|
45
45
|
expect(entry).toHaveLength(1);
|
|
46
46
|
expect(entry?.[0]?.featureName).toBe("test");
|
|
47
47
|
});
|
|
@@ -56,7 +56,7 @@ describe("postQuery hook registration", () => {
|
|
|
56
56
|
r.entityHook("postQuery", thing, hookB);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
expect(feature.entityHooks
|
|
59
|
+
expect(feature.entityHooks?.postQuery?.["thing"]).toHaveLength(2);
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
@@ -14,8 +14,8 @@ describe("validation hooks", () => {
|
|
|
14
14
|
});
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
expect(feature.hooks["validation"]).toBeDefined();
|
|
18
|
-
expect(feature.hooks["validation"]?.["user:create"]).toBeDefined();
|
|
17
|
+
expect(feature.hooks?.["validation"]).toBeDefined();
|
|
18
|
+
expect(feature.hooks?.["validation"]?.["user:create"]).toBeDefined();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
test("runValidation returns null when valid", () => {
|
|
@@ -145,7 +145,7 @@ export function validateMultiStreamProjections(feature: FeatureDefinition): void
|
|
|
145
145
|
// ohne das `fooTz`-Feld zu deklarieren. Der `locatedTimestamp(name)` Helper
|
|
146
146
|
// macht das Pair atomar — wer ihn nutzt, fliegt nicht durch diesen Validator.
|
|
147
147
|
export function validateLocatedTimestamps(feature: FeatureDefinition): void {
|
|
148
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
148
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
149
149
|
const fields = entity.fields;
|
|
150
150
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
151
151
|
if (field.type !== "timestamp" || field.locatedBy === undefined) continue;
|
|
@@ -182,7 +182,7 @@ export function validateLocatedTimestamps(feature: FeatureDefinition): void {
|
|
|
182
182
|
// (`["tenantId", "key"]` ist sinnvoll), nur die rein-tenantId-Single-
|
|
183
183
|
// column-Form blockieren wir.
|
|
184
184
|
export function validateEntityIndexes(feature: FeatureDefinition): void {
|
|
185
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
185
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
186
186
|
if (!entity.indexes) continue;
|
|
187
187
|
const fieldNames = new Set(Object.keys(entity.fields));
|
|
188
188
|
for (const [idx, def] of entity.indexes.entries()) {
|
|
@@ -242,7 +242,7 @@ export function validateEntityIndexes(feature: FeatureDefinition): void {
|
|
|
242
242
|
|
|
243
243
|
export function validateEncryptedFields(feature: FeatureDefinition): boolean {
|
|
244
244
|
let found = false;
|
|
245
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
245
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
246
246
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
247
247
|
// Beide string-typed fields können encrypted sein. Die
|
|
248
248
|
// searchable/sortable-Konflikt-Checks gelten nur für `text`
|
|
@@ -271,7 +271,7 @@ export function validateEncryptedFields(feature: FeatureDefinition): boolean {
|
|
|
271
271
|
// --- File field detection ---
|
|
272
272
|
|
|
273
273
|
export function validateFileFields(feature: FeatureDefinition): boolean {
|
|
274
|
-
for (const entity of Object.values(feature.entities)) {
|
|
274
|
+
for (const entity of Object.values(feature.entities ?? {})) {
|
|
275
275
|
for (const field of Object.values(entity.fields)) {
|
|
276
276
|
if (FILE_FIELD_TYPES.has(field.type)) return true;
|
|
277
277
|
}
|
|
@@ -296,7 +296,7 @@ export function validateReferenceFields(
|
|
|
296
296
|
feature: FeatureDefinition,
|
|
297
297
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
298
298
|
): void {
|
|
299
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
299
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
300
300
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
301
301
|
if (field.type !== "reference") continue;
|
|
302
302
|
|
|
@@ -310,9 +310,12 @@ export function validateReferenceFields(
|
|
|
310
310
|
`Known features: ${knownFeatures}.`,
|
|
311
311
|
);
|
|
312
312
|
}
|
|
313
|
-
const targetEntity = targetFeature.entities[target.entityName];
|
|
313
|
+
const targetEntity = targetFeature.entities?.[target.entityName];
|
|
314
314
|
if (!targetEntity) {
|
|
315
|
-
const known =
|
|
315
|
+
const known =
|
|
316
|
+
Object.keys(targetFeature.entities ?? {})
|
|
317
|
+
.sort()
|
|
318
|
+
.join(", ") || "(none)";
|
|
316
319
|
const where =
|
|
317
320
|
target.featureName === feature.name
|
|
318
321
|
? `in this feature`
|
|
@@ -354,7 +357,7 @@ export function validateReferenceFields(
|
|
|
354
357
|
}
|
|
355
358
|
|
|
356
359
|
export function validateEmbeddedFields(feature: FeatureDefinition): void {
|
|
357
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
360
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
358
361
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
359
362
|
if (field.type !== "embedded") continue;
|
|
360
363
|
|
|
@@ -382,7 +385,7 @@ export function validateEmbeddedFields(feature: FeatureDefinition): void {
|
|
|
382
385
|
// auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
|
|
383
386
|
// die früheste Stelle für klare Fehlermeldungen.
|
|
384
387
|
export function validateMultiSelectFields(feature: FeatureDefinition): void {
|
|
385
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
388
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
386
389
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
387
390
|
if (field.type !== "multiSelect") continue;
|
|
388
391
|
|
|
@@ -409,7 +412,7 @@ export function validateMultiSelectFields(feature: FeatureDefinition): void {
|
|
|
409
412
|
// --- Transition validation ---
|
|
410
413
|
|
|
411
414
|
export function validateTransitions(feature: FeatureDefinition): void {
|
|
412
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
415
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
413
416
|
if (!entity.transitions) continue;
|
|
414
417
|
|
|
415
418
|
for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
|
|
@@ -451,7 +454,7 @@ export function validateTransitions(feature: FeatureDefinition): void {
|
|
|
451
454
|
// --- extendSchema column collision detection ---
|
|
452
455
|
|
|
453
456
|
export function validateExtendSchemaCollisions(feature: FeatureDefinition): void {
|
|
454
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
457
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
455
458
|
const existingFields = new Set(Object.keys(entity.fields));
|
|
456
459
|
|
|
457
460
|
// Check if any registered extension would collide with existing fields
|