@cosmicdrift/kumiko-framework 0.156.2 → 0.157.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/__tests__/{raw-table.integration.test.ts → store-table.integration.test.ts} +10 -10
- package/src/api/__tests__/batch.integration.test.ts +68 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/collect-table-metas.ts +4 -4
- package/src/db/entity-table-meta.ts +5 -4
- package/src/db/feature-table-sources.ts +3 -3
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +96 -0
- package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
- package/src/engine/boot-validator/action-wiring.ts +67 -17
- package/src/engine/define-feature.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/extractors/index.ts +1 -1
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-builder-state.ts +3 -3
- package/src/engine/feature-ui-extensions.ts +54 -33
- package/src/engine/registry-facade.ts +3 -3
- package/src/engine/registry-ingest.ts +6 -6
- package/src/engine/registry-state.ts +3 -3
- package/src/engine/types/feature.ts +17 -17
- package/src/engine/types/index.ts +3 -3
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/file-ref-entity.ts +2 -2
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/search/__tests__/reindex-entity.integration.test.ts +121 -0
- package/src/search/index.ts +6 -0
- package/src/search/reindex-entity.ts +148 -0
- package/src/stack/push-entity-projection-tables.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.157.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>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.157.0",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Integration test for r.
|
|
2
|
-
// defineFeature declares a
|
|
1
|
+
// Integration test for r.storeTable() — proves the full boot path:
|
|
2
|
+
// defineFeature declares a store table → setupTestStack auto-pushes it →
|
|
3
3
|
// INSERT/SELECT against the real DB roundtrip. Plan reference:
|
|
4
4
|
// kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
|
|
5
5
|
|
|
@@ -9,7 +9,7 @@ import { asRawClient, insertOne, selectMany } from "../db/query";
|
|
|
9
9
|
import { defineFeature } from "../engine";
|
|
10
10
|
import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
|
|
11
11
|
|
|
12
|
-
// External-system payload cache — the textbook r.
|
|
12
|
+
// External-system payload cache — the textbook r.storeTable() use case:
|
|
13
13
|
// write-only by an integration handler, read-only by a query, never
|
|
14
14
|
// event-sourced (the data isn't a domain fact, it's a side-effect
|
|
15
15
|
// snapshot).
|
|
@@ -23,7 +23,7 @@ const stripeWebhookCacheMeta = defineUnmanagedTable({
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
const webhookCacheFeature = defineFeature("webhook-cache", (r) => {
|
|
26
|
-
r.
|
|
26
|
+
r.storeTable(stripeWebhookCacheMeta, {
|
|
27
27
|
reason: "external Stripe webhook payload cache — write-only by webhook handler",
|
|
28
28
|
});
|
|
29
29
|
});
|
|
@@ -38,7 +38,7 @@ afterAll(async () => {
|
|
|
38
38
|
await stack.cleanup();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
describe("r.
|
|
41
|
+
describe("r.storeTable — DB roundtrip via setupTestStack", () => {
|
|
42
42
|
test("table is auto-pushed and accepts INSERT + SELECT", async () => {
|
|
43
43
|
const eventId = "evt_test_123";
|
|
44
44
|
const payload = JSON.stringify({ type: "invoice.paid", amount: 4200 });
|
|
@@ -52,8 +52,8 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
52
52
|
expect(rows[0]?.receivedAt).toBeInstanceOf(Temporal.Instant);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("registry exposes the
|
|
56
|
-
const all = stack.registry.
|
|
55
|
+
test("registry exposes the store table with its reason and featureName", () => {
|
|
56
|
+
const all = stack.registry.getAllStoreTables();
|
|
57
57
|
const entry = all.get("rt_int_stripe_webhook_cache");
|
|
58
58
|
expect(entry).toBeDefined();
|
|
59
59
|
expect(entry?.featureName).toBe("webhook-cache");
|
|
@@ -62,9 +62,9 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("INSERT bypasses the event store — no kumiko_events row produced", async () => {
|
|
65
|
-
// Proves that the
|
|
65
|
+
// Proves that the storeTable lives outside the event-sourcing graph:
|
|
66
66
|
// a write to it shouldn't append anything to kumiko_events. If a
|
|
67
|
-
// future regression accidentally routed
|
|
67
|
+
// future regression accidentally routed store-table writes through
|
|
68
68
|
// the executor, a row would show up here.
|
|
69
69
|
const before = await asRawClient(stack.db).unsafe<{ count: string }>(
|
|
70
70
|
`SELECT COUNT(*)::text AS count FROM kumiko_events`,
|
|
@@ -84,7 +84,7 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
84
84
|
expect(afterCount).toBe(beforeCount);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
test("a second push on the same
|
|
87
|
+
test("a second push on the same storeTable is idempotent — CREATE IF NOT EXISTS", async () => {
|
|
88
88
|
// unsafePushTables uses CREATE TABLE IF NOT EXISTS — idempotent by design.
|
|
89
89
|
await expect(
|
|
90
90
|
unsafePushTables(stack.db, { idem: stripeWebhookCacheMeta }),
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type SaveContext,
|
|
16
16
|
} from "../../engine";
|
|
17
17
|
import { UnprocessableError, writeFailure } from "../../errors";
|
|
18
|
+
import { RedisKeys } from "../../pipeline/redis-keys";
|
|
18
19
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
19
20
|
|
|
20
21
|
// Entity: a simple "item" with name + counter
|
|
@@ -370,6 +371,73 @@ describe("POST /api/batch", () => {
|
|
|
370
371
|
expect(inTxHookLog).toHaveLength(1);
|
|
371
372
|
expect(afterCommitHookLog).toHaveLength(1);
|
|
372
373
|
});
|
|
374
|
+
|
|
375
|
+
test("idempotency: failed batch is cached — retry returns same error without re-exec", async () => {
|
|
376
|
+
const requestId = "batch-rid-fail-cache";
|
|
377
|
+
const commands = [
|
|
378
|
+
{ type: "batch:write:item:create", payload: { name: "should-rollback" } },
|
|
379
|
+
{ type: "batch:write:item:fail", payload: { name: "stop" } },
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const first = await stack.http.batch(commands, admin, requestId);
|
|
383
|
+
const firstBody = await first.json();
|
|
384
|
+
expect(first.status).toBe(422);
|
|
385
|
+
expect(firstBody.isSuccess).toBe(false);
|
|
386
|
+
expect(firstBody.failedIndex).toBe(1);
|
|
387
|
+
expect(inTxHookLog.map((h) => h.name)).toEqual(["should-rollback"]);
|
|
388
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
389
|
+
|
|
390
|
+
inTxHookLog.length = 0;
|
|
391
|
+
afterCommitHookLog.length = 0;
|
|
392
|
+
|
|
393
|
+
const second = await stack.http.batch(commands, admin, requestId);
|
|
394
|
+
const secondBody = await second.json();
|
|
395
|
+
expect(second.status).toBe(422);
|
|
396
|
+
expect(secondBody.isSuccess).toBe(false);
|
|
397
|
+
expect(secondBody.failedIndex).toBe(firstBody.failedIndex);
|
|
398
|
+
// HTTP re-serializes the cached failure with a fresh requestId/timestamp —
|
|
399
|
+
// pin the stable business fields, not the transport envelope.
|
|
400
|
+
expect(secondBody.error.code).toBe(firstBody.error.code);
|
|
401
|
+
expect(secondBody.error.details).toEqual(firstBody.error.details);
|
|
402
|
+
// Cached path must not re-run commands (hooks stay empty, DB empty)
|
|
403
|
+
expect(inTxHookLog).toHaveLength(0);
|
|
404
|
+
expect(afterCommitHookLog).toHaveLength(0);
|
|
405
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
|
|
409
|
+
const requestId = "batch-rid-corrupt";
|
|
410
|
+
await stack.redis.redis.set(`${RedisKeys.idempotency}${requestId}`, "{not-json", "EX", 60);
|
|
411
|
+
|
|
412
|
+
const res = await stack.http.batch(
|
|
413
|
+
[{ type: "batch:write:item:create", payload: { name: "after-corrupt" } }],
|
|
414
|
+
admin,
|
|
415
|
+
requestId,
|
|
416
|
+
);
|
|
417
|
+
const body = await res.json();
|
|
418
|
+
expect(res.status).toBe(200);
|
|
419
|
+
expect(body.isSuccess).toBe(true);
|
|
420
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(1);
|
|
421
|
+
expect(inTxHookLog.map((h) => h.name)).toEqual(["after-corrupt"]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("mid-batch handler throw: rolls back prior writes, afterCommit does not fire", async () => {
|
|
425
|
+
const res = await stack.http.batch(
|
|
426
|
+
[
|
|
427
|
+
{ type: "batch:write:item:create", payload: { name: "before-throw" } },
|
|
428
|
+
{ type: "batch:write:item:throw", payload: { name: "boom" } },
|
|
429
|
+
{ type: "batch:write:item:create", payload: { name: "never" } },
|
|
430
|
+
],
|
|
431
|
+
admin,
|
|
432
|
+
);
|
|
433
|
+
const body = await res.json();
|
|
434
|
+
expect(body.isSuccess).toBe(false);
|
|
435
|
+
expect(body.failedIndex).toBe(1);
|
|
436
|
+
expect(inTxHookLog.map((h) => h.name)).toEqual(["before-throw"]);
|
|
437
|
+
expect(afterCommitHookLog).toEqual([]);
|
|
438
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
439
|
+
expect(await selectMany(stack.db, auditTable)).toHaveLength(0);
|
|
440
|
+
});
|
|
373
441
|
});
|
|
374
442
|
|
|
375
443
|
describe("POST /api/write (single write runs in its own transaction)", () => {
|
|
@@ -20,7 +20,7 @@ const counterTable = table("read_unit_counters", {
|
|
|
20
20
|
}) as unknown as SchemaTable;
|
|
21
21
|
|
|
22
22
|
describe("collectTableMetas (#255)", () => {
|
|
23
|
-
test("collects entity, projection, MSP and
|
|
23
|
+
test("collects entity, projection, MSP and storeTable tables — same sources as the test-stack auto-push", () => {
|
|
24
24
|
const feature = defineFeature("test", (r) => {
|
|
25
25
|
r.entity("unit", exampleEntity());
|
|
26
26
|
r.projection({
|
|
@@ -39,16 +39,16 @@ describe("collectTableMetas (#255)", () => {
|
|
|
39
39
|
});
|
|
40
40
|
// side-effect-only MSP — no table, must be skipped without throwing
|
|
41
41
|
r.multiStreamProjection({ name: "unit-notify", apply: { "unit.created": async () => {} } });
|
|
42
|
-
r.
|
|
42
|
+
r.storeTable(
|
|
43
43
|
defineUnmanagedTable({
|
|
44
44
|
tableName: "unit_cache",
|
|
45
45
|
columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
|
|
46
46
|
}),
|
|
47
47
|
{ reason: "test fixture" },
|
|
48
48
|
);
|
|
49
|
-
r.
|
|
49
|
+
r.storeTable(
|
|
50
50
|
defineUnmanagedTable({
|
|
51
|
-
tableName: "
|
|
51
|
+
tableName: "store_unit_log",
|
|
52
52
|
columns: [{ name: "id", pgType: "serial", notNull: true, primaryKey: true }],
|
|
53
53
|
}),
|
|
54
54
|
{ reason: "test fixture" },
|
|
@@ -59,8 +59,8 @@ describe("collectTableMetas (#255)", () => {
|
|
|
59
59
|
expect(names).toContain("read_units"); // entity
|
|
60
60
|
expect(names).toContain("read_unit_counters"); // r.projection
|
|
61
61
|
expect(names).toContain("read_unit_audit"); // r.multiStreamProjection
|
|
62
|
-
expect(names).toContain("unit_cache"); // r.
|
|
63
|
-
expect(names).toContain("
|
|
62
|
+
expect(names).toContain("unit_cache"); // r.storeTable
|
|
63
|
+
expect(names).toContain("store_unit_log"); // r.storeTable (second registration)
|
|
64
64
|
expect(names).toHaveLength(5);
|
|
65
65
|
});
|
|
66
66
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// #1210: buildEntityTableMeta() hardcoded source: "managed", so unmanaged
|
|
2
|
+
// direct-write stores (store_user_sessions, store_api_tokens, mail sync/seen
|
|
3
|
+
// cursors) were misclassified as rebuildable event-sourced projections — a
|
|
4
|
+
// destructive column change would DROP+rebuild-from-events tables that have
|
|
5
|
+
// no events, wiping live data. The options.source escape hatch must produce
|
|
6
|
+
// a byte-identical column/piiSubjectFields shape (only source differs), so
|
|
7
|
+
// the migration diff for an existing table stays empty when flipping it.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { createEntity, createTextField } from "../../engine";
|
|
11
|
+
import { buildEntityTableMeta } from "../entity-table-meta";
|
|
12
|
+
import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
|
|
13
|
+
|
|
14
|
+
const entity = createEntity({
|
|
15
|
+
table: "source-probe",
|
|
16
|
+
fields: {
|
|
17
|
+
userId: createTextField({ required: true }),
|
|
18
|
+
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("buildEntityTableMeta — options.source (#1210)", () => {
|
|
23
|
+
test("defaults to managed when omitted", () => {
|
|
24
|
+
expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("options.source: 'unmanaged' changes only source, columns + piiSubjectFields stay identical", () => {
|
|
28
|
+
const managed = buildEntityTableMeta("source-probe", entity);
|
|
29
|
+
const unmanaged = buildEntityTableMeta("source-probe", entity, { source: "unmanaged" });
|
|
30
|
+
|
|
31
|
+
expect(managed.source).toBe("managed");
|
|
32
|
+
expect(unmanaged.source).toBe("unmanaged");
|
|
33
|
+
expect(unmanaged.columns).toEqual(managed.columns);
|
|
34
|
+
expect(unmanaged.indexes).toEqual(managed.indexes);
|
|
35
|
+
expect(unmanaged.piiSubjectFields).toEqual(managed.piiSubjectFields);
|
|
36
|
+
expect(unmanaged.piiSubjectFields).toEqual(["ip"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("flipping an existing table's meta to unmanaged produces an empty migration diff", () => {
|
|
40
|
+
const prevSnapshot = snapshotFromMetas([buildEntityTableMeta("source-probe", entity)]);
|
|
41
|
+
const nextSnapshot = snapshotFromMetas([
|
|
42
|
+
buildEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const diff = diffSnapshots(prevSnapshot, nextSnapshot);
|
|
46
|
+
expect(diff.newTables).toEqual([]);
|
|
47
|
+
expect(diff.droppedTables).toEqual([]);
|
|
48
|
+
expect(diff.changedTables).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Regression test for #255-class drift:
|
|
1
|
+
// Regression test for #255-class drift: storeTables must appear in
|
|
2
2
|
// enumerateFeatureTableSources so setupTestStack's auto-push and
|
|
3
3
|
// collectTableMetas see the exact same table set. Before this fix,
|
|
4
4
|
// unmanaged tables were only handled by collectTableMetas directly, so any
|
|
5
5
|
// app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
|
|
6
|
-
// its
|
|
7
|
-
//
|
|
6
|
+
// its store tables created — e.g. the bundled "sessions" feature's
|
|
7
|
+
// store_user_sessions, breaking every login in an ephemeral test stack.
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
10
|
import { defineFeature } from "../../engine/define-feature";
|
|
@@ -16,10 +16,10 @@ const probeMeta = defineUnmanagedTable({
|
|
|
16
16
|
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
describe("enumerateFeatureTableSources —
|
|
20
|
-
test("includes a feature's
|
|
19
|
+
describe("enumerateFeatureTableSources — storeTables", () => {
|
|
20
|
+
test("includes a feature's storeTable as a table source", () => {
|
|
21
21
|
const feature = defineFeature("probe", (r) => {
|
|
22
|
-
r.
|
|
22
|
+
r.storeTable(probeMeta, { reason: "direct-write store" });
|
|
23
23
|
});
|
|
24
24
|
const sources = enumerateFeatureTableSources(feature);
|
|
25
25
|
const entry = sources.find((s) => s.origin.includes("ftst_probe"));
|
|
@@ -27,7 +27,7 @@ describe("enumerateFeatureTableSources — rawTables", () => {
|
|
|
27
27
|
expect(entry?.table).toBe(probeMeta);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
test("a feature with no
|
|
30
|
+
test("a feature with no storeTable contributes nothing extra", () => {
|
|
31
31
|
const feature = defineFeature("plain", () => {});
|
|
32
32
|
expect(enumerateFeatureTableSources(feature)).toEqual([]);
|
|
33
33
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// collectTableMetas — kanonische ENTITY_METAS-Quelle für `kumiko schema
|
|
2
2
|
// generate`. Erfasst dieselben Tabellen-Quellen wie der setupTestStack-
|
|
3
|
-
// auto-push (entities,
|
|
4
|
-
// die frühere Template-Variante sammelte nur entities +
|
|
3
|
+
// auto-push (entities, storeTables, projections, multiStreamProjections) —
|
|
4
|
+
// die frühere Template-Variante sammelte nur entities + storeTables, wodurch
|
|
5
5
|
// projection-only-Tabellen (z.B. billing-foundation read_subscriptions) nie
|
|
6
6
|
// in Migrations landeten und der erste Prod-Write crashte (#255).
|
|
7
7
|
|
|
@@ -59,11 +59,11 @@ export function collectTableMetas(
|
|
|
59
59
|
metas.push(meta);
|
|
60
60
|
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
|
61
61
|
}
|
|
62
|
-
for (const entry of Object.values(feature.
|
|
62
|
+
for (const entry of Object.values(feature.storeTables)) {
|
|
63
63
|
metas.push(entry.meta);
|
|
64
64
|
byName.set(entry.meta.tableName, {
|
|
65
65
|
meta: entry.meta,
|
|
66
|
-
origin: `
|
|
66
|
+
origin: `storeTable "${entry.name}" (${feature.name})`,
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -95,7 +95,7 @@ export type EntityTableMeta = {
|
|
|
95
95
|
// discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
|
|
96
96
|
readonly source: "managed" | "unmanaged";
|
|
97
97
|
// PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
|
|
98
|
-
// buildEntityTableMeta so the registry can reject r.
|
|
98
|
+
// buildEntityTableMeta so the registry can reject r.storeTable stores
|
|
99
99
|
// whose direct writes would skip the executor's encryption (#820).
|
|
100
100
|
readonly piiSubjectFields?: readonly string[];
|
|
101
101
|
};
|
|
@@ -296,6 +296,7 @@ export function resolveTableName(
|
|
|
296
296
|
export type BuildEntityTableMetaOptions = {
|
|
297
297
|
readonly featureName?: string;
|
|
298
298
|
readonly relations?: EntityRelations;
|
|
299
|
+
readonly source?: "managed" | "unmanaged";
|
|
299
300
|
};
|
|
300
301
|
|
|
301
302
|
export function buildEntityTableMeta(
|
|
@@ -415,7 +416,7 @@ export function buildEntityTableMeta(
|
|
|
415
416
|
tableName,
|
|
416
417
|
columns,
|
|
417
418
|
indexes,
|
|
418
|
-
source: "managed",
|
|
419
|
+
source: options?.source ?? "managed",
|
|
419
420
|
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
420
421
|
};
|
|
421
422
|
}
|
|
@@ -431,8 +432,8 @@ export function buildEntityTableMeta(
|
|
|
431
432
|
// jede neue defineUnmanagedTable-Stelle prüfen.
|
|
432
433
|
//
|
|
433
434
|
// Heutige use-cases im framework:
|
|
434
|
-
// - `
|
|
435
|
-
// - `
|
|
435
|
+
// - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
|
|
436
|
+
// - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
|
|
436
437
|
export type UnmanagedTableInput = {
|
|
437
438
|
readonly tableName: string;
|
|
438
439
|
readonly columns: readonly ColumnMeta[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Single enumeration of every table-bearing registration on a feature
|
|
2
|
-
// (r.projection, r.multiStreamProjection with table, r.
|
|
2
|
+
// (r.projection, r.multiStreamProjection with table, r.storeTable).
|
|
3
3
|
// Consumed by BOTH the setupTestStack auto-push and collectTableMetas —
|
|
4
4
|
// one list, so test-DB-push and `kumiko schema generate` cannot drift
|
|
5
5
|
// apart again (#255).
|
|
@@ -29,8 +29,8 @@ export function enumerateFeatureTableSources(
|
|
|
29
29
|
origin: `multiStreamProjection "${name}" (${feature.name})`,
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
|
-
for (const [name, raw] of Object.entries(feature.
|
|
33
|
-
sources.push({ table: raw.meta, origin: `
|
|
32
|
+
for (const [name, raw] of Object.entries(feature.storeTables)) {
|
|
33
|
+
sources.push({ table: raw.meta, origin: `storeTable "${name}" (${feature.name})` });
|
|
34
34
|
}
|
|
35
35
|
return sources;
|
|
36
36
|
}
|
|
@@ -207,7 +207,7 @@ export async function assertNoUnreachableLiveRows(
|
|
|
207
207
|
`projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
|
|
208
208
|
`event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
|
|
209
209
|
`would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
|
|
210
|
-
`without emitting a .created event. Fix: register the table with r.
|
|
210
|
+
`without emitting a .created event. Fix: register the table with r.storeTable(meta, ` +
|
|
211
211
|
`{ reason }) to opt out of rebuild, or emit the missing events. See ` +
|
|
212
212
|
`docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
|
|
213
213
|
);
|
|
@@ -1297,6 +1297,102 @@ describe("boot-validator", () => {
|
|
|
1297
1297
|
});
|
|
1298
1298
|
});
|
|
1299
1299
|
|
|
1300
|
+
// --- function-renderer check on the non-entityEdit/entityList screen
|
|
1301
|
+
// types that share the same EditLayout/ListColumnSpec shapes
|
|
1302
|
+
// (action-wiring.ts's validateEditLayoutNoFunctions/validateColumnsNoFunctions) ---
|
|
1303
|
+
describe("function-renderer check on other EditLayout/columns screens", () => {
|
|
1304
|
+
test("actionForm field renderer as a function → Throw", () => {
|
|
1305
|
+
const features = [
|
|
1306
|
+
defineFeature("shop", (r) => {
|
|
1307
|
+
r.screen({
|
|
1308
|
+
id: "restock",
|
|
1309
|
+
type: "actionForm",
|
|
1310
|
+
handler: "shop:write:restock",
|
|
1311
|
+
fields: { qty: { type: "number" } } as never,
|
|
1312
|
+
layout: {
|
|
1313
|
+
sections: [
|
|
1314
|
+
{ fields: [{ field: "qty", renderer: (v: unknown) => String(v) }] as never },
|
|
1315
|
+
],
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
}),
|
|
1319
|
+
];
|
|
1320
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1321
|
+
/\(actionForm\) field "qty" renderer is a function/,
|
|
1322
|
+
);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test("configEdit field renderer as a function → Throw", () => {
|
|
1326
|
+
const features = [
|
|
1327
|
+
defineFeature("shop", (r) => {
|
|
1328
|
+
r.config({
|
|
1329
|
+
keys: { "site-name": createTenantConfig("text", { default: "" }) },
|
|
1330
|
+
});
|
|
1331
|
+
r.screen({
|
|
1332
|
+
id: "settings",
|
|
1333
|
+
type: "configEdit",
|
|
1334
|
+
scope: "tenant",
|
|
1335
|
+
configKeys: { siteName: "shop:config:site-name" },
|
|
1336
|
+
fields: { siteName: { type: "text" } } as never,
|
|
1337
|
+
layout: {
|
|
1338
|
+
sections: [
|
|
1339
|
+
{
|
|
1340
|
+
fields: [{ field: "siteName", renderer: (v: unknown) => String(v) }] as never,
|
|
1341
|
+
},
|
|
1342
|
+
],
|
|
1343
|
+
},
|
|
1344
|
+
});
|
|
1345
|
+
}),
|
|
1346
|
+
];
|
|
1347
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1348
|
+
/\(configEdit\) field "siteName" renderer is a function/,
|
|
1349
|
+
);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("projectionDetail field renderer as a function → Throw", () => {
|
|
1353
|
+
const features = [
|
|
1354
|
+
defineFeature("shop", (r) => {
|
|
1355
|
+
r.screen({
|
|
1356
|
+
id: "order-detail",
|
|
1357
|
+
type: "projectionDetail",
|
|
1358
|
+
query: "shop:query:order-detail",
|
|
1359
|
+
layout: {
|
|
1360
|
+
sections: [
|
|
1361
|
+
{ fields: [{ field: "total", renderer: (v: unknown) => String(v) }] as never },
|
|
1362
|
+
],
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
}),
|
|
1366
|
+
];
|
|
1367
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1368
|
+
/\(projectionDetail\) field "total" renderer is a function/,
|
|
1369
|
+
);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
test("dashboard list-panel column renderer as a function → Throw", () => {
|
|
1373
|
+
const features = [
|
|
1374
|
+
defineFeature("shop", (r) => {
|
|
1375
|
+
r.screen({
|
|
1376
|
+
id: "overview",
|
|
1377
|
+
type: "dashboard",
|
|
1378
|
+
panels: [
|
|
1379
|
+
{
|
|
1380
|
+
kind: "list",
|
|
1381
|
+
id: "recent-orders",
|
|
1382
|
+
label: "Recent Orders",
|
|
1383
|
+
query: "shop:query:recent-orders",
|
|
1384
|
+
columns: [{ field: "total", renderer: (v: unknown) => String(v) }] as never,
|
|
1385
|
+
},
|
|
1386
|
+
],
|
|
1387
|
+
});
|
|
1388
|
+
}),
|
|
1389
|
+
];
|
|
1390
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1391
|
+
/\(dashboard-list\) column "total" renderer is a function/,
|
|
1392
|
+
);
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1300
1396
|
// --- entityList virtual (labeled) columns ---
|
|
1301
1397
|
// A column whose `field` is not an entity field is allowed IF it carries a
|
|
1302
1398
|
// `label` — a presentational column drawn by a columnRenderer component (e.g.
|