@cosmicdrift/kumiko-framework 0.156.3 → 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 +1 -1
- 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 +3 -3
- package/src/db/feature-table-sources.ts +3 -3
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/engine/__tests__/{raw-table.test.ts → store-table.test.ts} +59 -42
- 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 +29 -6
- 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/migrations/pending-rebuilds.ts +1 -1
- 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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// #1210: buildEntityTableMeta() hardcoded source: "managed", so unmanaged
|
|
2
|
-
// direct-write stores (
|
|
2
|
+
// direct-write stores (store_user_sessions, store_api_tokens, mail sync/seen
|
|
3
3
|
// cursors) were misclassified as rebuildable event-sourced projections — a
|
|
4
4
|
// destructive column change would DROP+rebuild-from-events tables that have
|
|
5
5
|
// no events, wiping live data. The options.source escape hatch must produce
|
|
@@ -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
|
};
|
|
@@ -432,8 +432,8 @@ export function buildEntityTableMeta(
|
|
|
432
432
|
// jede neue defineUnmanagedTable-Stelle prüfen.
|
|
433
433
|
//
|
|
434
434
|
// Heutige use-cases im framework:
|
|
435
|
-
// - `
|
|
436
|
-
// - `
|
|
435
|
+
// - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
|
|
436
|
+
// - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
|
|
437
437
|
export type UnmanagedTableInput = {
|
|
438
438
|
readonly tableName: string;
|
|
439
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
|
);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Unit tests for r.
|
|
2
|
-
// aggregation. Post-drizzle-cut merge of the former r.
|
|
1
|
+
// Unit tests for r.storeTable() — declaration-time validation + registry
|
|
2
|
+
// aggregation. Post-drizzle-cut merge of the former r.storeTable() (legacy
|
|
3
3
|
// Drizzle PgTable) and r.unmanagedTable() (EntityTableMeta) — this file
|
|
4
4
|
// absorbs the former unmanaged-table.test.ts coverage under the new name.
|
|
5
5
|
// Full DB roundtrip (setupTestStack pushes the table → INSERT / SELECT)
|
|
6
|
-
// lives in src/__tests__/
|
|
6
|
+
// lives in src/__tests__/store-table.integration.test.ts.
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import {
|
|
@@ -24,7 +24,7 @@ const probeMetaTwo = defineUnmanagedTable({
|
|
|
24
24
|
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
describe("r.
|
|
27
|
+
describe("r.storeTable — declaration", () => {
|
|
28
28
|
test("rejects an invalid table name", () => {
|
|
29
29
|
const badMeta = defineUnmanagedTable({
|
|
30
30
|
tableName: "BadName",
|
|
@@ -32,7 +32,7 @@ describe("r.rawTable — declaration", () => {
|
|
|
32
32
|
});
|
|
33
33
|
expect(() =>
|
|
34
34
|
defineFeature("probe", (r) => {
|
|
35
|
-
r.
|
|
35
|
+
r.storeTable(badMeta, { reason: "test" });
|
|
36
36
|
}),
|
|
37
37
|
).toThrow(/must be a valid identifier/);
|
|
38
38
|
});
|
|
@@ -40,8 +40,8 @@ describe("r.rawTable — declaration", () => {
|
|
|
40
40
|
test("rejects duplicate registrations within one feature", () => {
|
|
41
41
|
expect(() =>
|
|
42
42
|
defineFeature("probe", (r) => {
|
|
43
|
-
r.
|
|
44
|
-
r.
|
|
43
|
+
r.storeTable(probeMeta, { reason: "test" });
|
|
44
|
+
r.storeTable(probeMeta, { reason: "test" });
|
|
45
45
|
}),
|
|
46
46
|
).toThrow(/already registered/);
|
|
47
47
|
});
|
|
@@ -49,7 +49,7 @@ describe("r.rawTable — declaration", () => {
|
|
|
49
49
|
test("rejects empty reason", () => {
|
|
50
50
|
expect(() =>
|
|
51
51
|
defineFeature("probe", (r) => {
|
|
52
|
-
r.
|
|
52
|
+
r.storeTable(probeMeta, { reason: "" });
|
|
53
53
|
}),
|
|
54
54
|
).toThrow(/options\.reason must be a non-empty string/);
|
|
55
55
|
});
|
|
@@ -57,49 +57,62 @@ describe("r.rawTable — declaration", () => {
|
|
|
57
57
|
test("rejects whitespace-only reason", () => {
|
|
58
58
|
expect(() =>
|
|
59
59
|
defineFeature("probe", (r) => {
|
|
60
|
-
r.
|
|
60
|
+
r.storeTable(probeMeta, { reason: " " });
|
|
61
61
|
}),
|
|
62
62
|
).toThrow(/options\.reason must be a non-empty string/);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test("rejects an EntityTableMeta whose source is not 'unmanaged' (#1209)", () => {
|
|
66
|
+
const managedEntity = createEntity({
|
|
67
|
+
table: "rt_probe_managed",
|
|
68
|
+
fields: { name: createTextField() },
|
|
69
|
+
});
|
|
70
|
+
const managedMeta = buildEntityTableMeta("rt-probe-managed", managedEntity);
|
|
71
|
+
expect(() =>
|
|
72
|
+
defineFeature("probe", (r) => {
|
|
73
|
+
r.storeTable(managedMeta, { reason: "test" });
|
|
74
|
+
}),
|
|
75
|
+
).toThrow(/requires source: "unmanaged"/);
|
|
76
|
+
});
|
|
77
|
+
|
|
65
78
|
test("accepts valid registration and stores meta + reason", () => {
|
|
66
79
|
const feature = defineFeature("probe", (r) => {
|
|
67
|
-
r.
|
|
80
|
+
r.storeTable(probeMeta, {
|
|
68
81
|
reason: "read-side projection of an event-stream",
|
|
69
82
|
});
|
|
70
83
|
});
|
|
71
|
-
expect(feature.
|
|
72
|
-
expect(feature.
|
|
73
|
-
expect(feature.
|
|
84
|
+
expect(feature.storeTables).toHaveProperty("rt_probe");
|
|
85
|
+
expect(feature.storeTables["rt_probe"]?.reason).toBe("read-side projection of an event-stream");
|
|
86
|
+
expect(feature.storeTables["rt_probe"]?.meta).toBe(probeMeta);
|
|
74
87
|
});
|
|
75
88
|
|
|
76
|
-
test("two
|
|
89
|
+
test("two store tables on one feature register under their tableName", () => {
|
|
77
90
|
const feature = defineFeature("dual", (r) => {
|
|
78
|
-
r.
|
|
79
|
-
r.
|
|
91
|
+
r.storeTable(probeMeta, { reason: "one" });
|
|
92
|
+
r.storeTable(probeMetaTwo, { reason: "two" });
|
|
80
93
|
});
|
|
81
|
-
expect(Object.keys(feature.
|
|
94
|
+
expect(Object.keys(feature.storeTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
|
|
82
95
|
});
|
|
83
96
|
|
|
84
|
-
test("absent
|
|
97
|
+
test("absent storeTables on a feature is ok", () => {
|
|
85
98
|
const feat = defineFeature("plain", () => {
|
|
86
|
-
// no r.
|
|
99
|
+
// no r.storeTable calls
|
|
87
100
|
});
|
|
88
|
-
expect(feat.
|
|
101
|
+
expect(feat.storeTables).toEqual({});
|
|
89
102
|
});
|
|
90
103
|
});
|
|
91
104
|
|
|
92
|
-
describe("createRegistry —
|
|
93
|
-
test("aggregates
|
|
105
|
+
describe("createRegistry — storeTable aggregation", () => {
|
|
106
|
+
test("aggregates store tables across features and tags featureName", () => {
|
|
94
107
|
const featA = defineFeature("billing", (r) => {
|
|
95
|
-
r.
|
|
108
|
+
r.storeTable(probeMeta, { reason: "external API cache" });
|
|
96
109
|
});
|
|
97
110
|
const featB = defineFeature("inventory", (r) => {
|
|
98
|
-
r.
|
|
111
|
+
r.storeTable(probeMetaTwo, { reason: "imported pre-ES" });
|
|
99
112
|
});
|
|
100
113
|
|
|
101
114
|
const registry = createRegistry([featA, featB]);
|
|
102
|
-
const all = registry.
|
|
115
|
+
const all = registry.getAllStoreTables();
|
|
103
116
|
|
|
104
117
|
expect(all.size).toBe(2);
|
|
105
118
|
expect(all.get("rt_probe")?.featureName).toBe("billing");
|
|
@@ -111,10 +124,10 @@ describe("createRegistry — rawTable aggregation", () => {
|
|
|
111
124
|
// Two features can't share the same physical tableName — migrate-runner
|
|
112
125
|
// would race two CREATE TABLE statements. Boot-validator catches it.
|
|
113
126
|
const featA = defineFeature("a", (r) => {
|
|
114
|
-
r.
|
|
127
|
+
r.storeTable(probeMeta, { reason: "first" });
|
|
115
128
|
});
|
|
116
129
|
const featB = defineFeature("b", (r) => {
|
|
117
|
-
r.
|
|
130
|
+
r.storeTable(probeMeta, { reason: "second" });
|
|
118
131
|
});
|
|
119
132
|
expect(() => createRegistry([featA, featB])).toThrow(
|
|
120
133
|
/Raw-table "rt_probe" registered by both feature "a" and "b"/,
|
|
@@ -123,16 +136,20 @@ describe("createRegistry — rawTable aggregation", () => {
|
|
|
123
136
|
|
|
124
137
|
test("two features with distinct tableNames register cleanly", () => {
|
|
125
138
|
const featA = defineFeature("a", (r) => {
|
|
126
|
-
r.
|
|
139
|
+
r.storeTable(probeMeta, { reason: "first" });
|
|
127
140
|
});
|
|
128
141
|
const featB = defineFeature("b", (r) => {
|
|
129
|
-
r.
|
|
142
|
+
r.storeTable(probeMetaTwo, { reason: "second" });
|
|
130
143
|
});
|
|
131
144
|
expect(() => createRegistry([featA, featB])).not.toThrow();
|
|
132
145
|
});
|
|
133
146
|
|
|
134
|
-
test("rejects a
|
|
135
|
-
|
|
147
|
+
test("rejects a store-table that collides with an entity's physical name", () => {
|
|
148
|
+
// Custom `table` override without the read_ prefix — resolveTableName's
|
|
149
|
+
// default would carry read_, which r.storeTable now rejects outright
|
|
150
|
+
// (#1220), so the collision case needs a table name storeTable can
|
|
151
|
+
// actually register.
|
|
152
|
+
const widget = createEntity({ table: "shop_widgets", fields: { name: createTextField() } });
|
|
136
153
|
// resolveTableName mirrors the migrate-runner — pin the exact physical name.
|
|
137
154
|
const physical = resolveTableName("widget", widget, "shop");
|
|
138
155
|
const clashing = defineUnmanagedTable({
|
|
@@ -143,22 +160,22 @@ describe("createRegistry — rawTable aggregation", () => {
|
|
|
143
160
|
r.entity("widget", widget);
|
|
144
161
|
});
|
|
145
162
|
const tableFeature = defineFeature("other", (r) => {
|
|
146
|
-
r.
|
|
163
|
+
r.storeTable(clashing, { reason: "clash" });
|
|
147
164
|
});
|
|
148
165
|
|
|
149
|
-
// Entity registered first, then the colliding
|
|
166
|
+
// Entity registered first, then the colliding store table.
|
|
150
167
|
expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
|
|
151
168
|
new RegExp(`Raw-table "${physical}".*collides with the physical table of entity "widget"`),
|
|
152
169
|
);
|
|
153
170
|
|
|
154
|
-
// Order-independent:
|
|
171
|
+
// Order-independent: store table registered first, then the entity.
|
|
155
172
|
expect(() => createRegistry([tableFeature, entityFeature])).toThrow(
|
|
156
|
-
new RegExp(`Entity "widget".*collides with r.
|
|
173
|
+
new RegExp(`Entity "widget".*collides with r.storeTable\\("${physical}"\\)`),
|
|
157
174
|
);
|
|
158
175
|
});
|
|
159
176
|
});
|
|
160
177
|
|
|
161
|
-
describe("createRegistry —
|
|
178
|
+
describe("createRegistry — store tables with PII-annotated fields (#820)", () => {
|
|
162
179
|
const piiEntity = createEntity({
|
|
163
180
|
table: "rt_pii_probe",
|
|
164
181
|
fields: {
|
|
@@ -166,15 +183,15 @@ describe("createRegistry — raw tables with PII-annotated fields (#820)", () =>
|
|
|
166
183
|
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
167
184
|
},
|
|
168
185
|
});
|
|
169
|
-
const piiMeta = buildEntityTableMeta("rt-pii-probe", piiEntity);
|
|
186
|
+
const piiMeta = buildEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
|
|
170
187
|
|
|
171
188
|
test("buildEntityTableMeta records the subject-annotated field names", () => {
|
|
172
189
|
expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
|
|
173
190
|
});
|
|
174
191
|
|
|
175
|
-
test("rejects a PII-carrying
|
|
192
|
+
test("rejects a PII-carrying store table without piiEncryptedOnWrite", () => {
|
|
176
193
|
const feat = defineFeature("probe", (r) => {
|
|
177
|
-
r.
|
|
194
|
+
r.storeTable(piiMeta, { reason: "direct-write store" });
|
|
178
195
|
});
|
|
179
196
|
expect(() => createRegistry([feat])).toThrow(
|
|
180
197
|
/has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
|
|
@@ -183,7 +200,7 @@ describe("createRegistry — raw tables with PII-annotated fields (#820)", () =>
|
|
|
183
200
|
|
|
184
201
|
test("accepts it once the feature declares piiEncryptedOnWrite", () => {
|
|
185
202
|
const feat = defineFeature("probe", (r) => {
|
|
186
|
-
r.
|
|
203
|
+
r.storeTable(piiMeta, {
|
|
187
204
|
reason: "direct-write store",
|
|
188
205
|
piiEncryptedOnWrite: true,
|
|
189
206
|
});
|
|
@@ -191,9 +208,9 @@ describe("createRegistry — raw tables with PII-annotated fields (#820)", () =>
|
|
|
191
208
|
expect(() => createRegistry([feat])).not.toThrow();
|
|
192
209
|
});
|
|
193
210
|
|
|
194
|
-
test("a PII-free
|
|
211
|
+
test("a PII-free store table needs no declaration", () => {
|
|
195
212
|
const feat = defineFeature("probe", (r) => {
|
|
196
|
-
r.
|
|
213
|
+
r.storeTable(probeMeta, { reason: "plain store" });
|
|
197
214
|
});
|
|
198
215
|
expect(() => createRegistry([feat])).not.toThrow();
|
|
199
216
|
});
|
|
@@ -152,7 +152,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
152
152
|
navs: state.navs,
|
|
153
153
|
workspaces: state.workspaces,
|
|
154
154
|
httpRoutes: state.httpRoutes,
|
|
155
|
-
|
|
155
|
+
storeTables: state.storeTables,
|
|
156
156
|
...(state.treeActions !== undefined && { treeActions: state.treeActions }),
|
|
157
157
|
...(state.envSchema !== undefined && { envSchema: state.envSchema }),
|
|
158
158
|
};
|
|
@@ -69,7 +69,7 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
69
69
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
70
70
|
expectedFeatureName: "sessions",
|
|
71
71
|
recognisedKinds: ["hook", "writeHandler", "queryHandler"],
|
|
72
|
-
errorMethodNames: ["
|
|
72
|
+
errorMethodNames: ["storeTable", "job"],
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
path: "packages/bundled-features/src/auth-email-password/feature.ts",
|
|
@@ -95,7 +95,7 @@ export function extractExposesApi(
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function
|
|
98
|
+
export function extractStoreTable(
|
|
99
99
|
call: CallExpression,
|
|
100
100
|
sourceFile: SourceFile,
|
|
101
101
|
): ExtractOutput<never> {
|
|
@@ -104,8 +104,8 @@ export function extractRawTable(
|
|
|
104
104
|
// so there is nothing to extract statically. A clean ParseError (not
|
|
105
105
|
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
106
106
|
return fail(
|
|
107
|
-
"
|
|
107
|
+
"storeTable",
|
|
108
108
|
sourceLocationFromNode(call, sourceFile),
|
|
109
|
-
"
|
|
109
|
+
"storeTable meta is a factory/identifier argument, not a static literal",
|
|
110
110
|
);
|
|
111
111
|
}
|
|
@@ -51,13 +51,13 @@ import {
|
|
|
51
51
|
extractOptionalRequires,
|
|
52
52
|
extractProjection,
|
|
53
53
|
extractQueryHandler,
|
|
54
|
-
extractRawTable,
|
|
55
54
|
extractReadsConfig,
|
|
56
55
|
extractReferenceData,
|
|
57
56
|
extractRelation,
|
|
58
57
|
extractRequires,
|
|
59
58
|
extractScreen,
|
|
60
59
|
extractSecret,
|
|
60
|
+
extractStoreTable,
|
|
61
61
|
extractSystemScope,
|
|
62
62
|
extractToggleable,
|
|
63
63
|
extractTranslations,
|
|
@@ -468,8 +468,8 @@ function dispatchExtractor(
|
|
|
468
468
|
return extractUsesApi(call, sourceFile);
|
|
469
469
|
case "exposesApi":
|
|
470
470
|
return extractExposesApi(call, sourceFile);
|
|
471
|
-
case "
|
|
472
|
-
return
|
|
471
|
+
case "storeTable":
|
|
472
|
+
return extractStoreTable(call, sourceFile);
|
|
473
473
|
// Round 6 — Tree-Actions pattern
|
|
474
474
|
case "treeActions":
|
|
475
475
|
return extractTreeActions(call, sourceFile);
|
|
@@ -22,13 +22,13 @@ import type {
|
|
|
22
22
|
PreDeleteHookFn,
|
|
23
23
|
ProjectionDefinition,
|
|
24
24
|
QueryHandlerDef,
|
|
25
|
-
RawTableEntry,
|
|
26
25
|
ReferenceDataDef,
|
|
27
26
|
RegistrarExtensionDef,
|
|
28
27
|
RegistrarExtensionRegistration,
|
|
29
28
|
RelationDefinition,
|
|
30
29
|
SearchPayloadContributorFn,
|
|
31
30
|
SecretKeyDefinition,
|
|
31
|
+
StoreTableEntry,
|
|
32
32
|
TranslationKeys,
|
|
33
33
|
TreeActionDef,
|
|
34
34
|
UiHints,
|
|
@@ -86,7 +86,7 @@ export type FeatureBuilderState = {
|
|
|
86
86
|
projections: Record<string, ProjectionDefinition>;
|
|
87
87
|
multiStreamProjections: Record<string, MultiStreamProjectionDefinition>;
|
|
88
88
|
entityProjectionExtensions: Record<string, EntityProjectionExtension[]>;
|
|
89
|
-
|
|
89
|
+
storeTables: Record<string, StoreTableEntry>;
|
|
90
90
|
authClaimsHooks: AuthClaimsFn[];
|
|
91
91
|
claimKeys: Record<string, ClaimKeyDefinition>;
|
|
92
92
|
screens: Record<string, ScreenDefinition>;
|
|
@@ -144,7 +144,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
|
|
|
144
144
|
projections: {},
|
|
145
145
|
multiStreamProjections: {},
|
|
146
146
|
entityProjectionExtensions: {},
|
|
147
|
-
|
|
147
|
+
storeTables: {},
|
|
148
148
|
authClaimsHooks: [],
|
|
149
149
|
claimKeys: {},
|
|
150
150
|
screens: {},
|
|
@@ -14,9 +14,9 @@ import type {
|
|
|
14
14
|
PostSaveHookFn,
|
|
15
15
|
PreDeleteHookFn,
|
|
16
16
|
ProjectionDefinition,
|
|
17
|
-
RawTableOptions,
|
|
18
17
|
RegistrarExtensionDef,
|
|
19
18
|
SearchPayloadContributorFn,
|
|
19
|
+
StoreTableOptions,
|
|
20
20
|
TreeActionDef,
|
|
21
21
|
TreeActionsHandle,
|
|
22
22
|
ValidationHookFn,
|
|
@@ -414,7 +414,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
414
414
|
}
|
|
415
415
|
state.httpRoutes[key] = definition;
|
|
416
416
|
},
|
|
417
|
-
|
|
417
|
+
storeTable(meta: EntityTableMeta, options: StoreTableOptions): void {
|
|
418
418
|
// Name comes from the meta itself — apps already give the table a
|
|
419
419
|
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
420
420
|
const tableName = meta.tableName;
|
|
@@ -426,23 +426,46 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
426
426
|
`valid identifier (lowercase letters, digits, underscores; start with a letter).`,
|
|
427
427
|
);
|
|
428
428
|
}
|
|
429
|
-
if (state.
|
|
429
|
+
if (state.storeTables[tableName]) {
|
|
430
430
|
throw new Error(
|
|
431
|
-
`[Feature ${name}] r.
|
|
431
|
+
`[Feature ${name}] r.storeTable("${tableName}") already registered. ` +
|
|
432
432
|
`Raw-table names must be unique per feature.`,
|
|
433
433
|
);
|
|
434
434
|
}
|
|
435
|
+
// `read_` is reserved for r.entity()/r.projection() (managed,
|
|
436
|
+
// event-sourced, rebuildable). storeTable is the unmanaged
|
|
437
|
+
// direct-write escape hatch — the prefix must say so (#1220).
|
|
438
|
+
if (tableName.startsWith("read_")) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`[Feature ${name}] r.storeTable("${tableName}"): the "read_" prefix is reserved ` +
|
|
441
|
+
`for managed r.entity()/r.projection() tables. Pick an unprefixed name or a ` +
|
|
442
|
+
`distinct prefix (e.g. "store_${tableName.slice("read_".length)}").`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
// meta.source must agree with the r.storeTable() escape hatch, or the
|
|
446
|
+
// migrate-generator treats schema drift on this table as safe to
|
|
447
|
+
// DROP+rebuild-from-events — wiping direct-write data with no events
|
|
448
|
+
// to replay it from (#1209).
|
|
449
|
+
if (meta.source !== "unmanaged") {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`[Feature ${name}] r.storeTable("${tableName}") was given an EntityTableMeta with ` +
|
|
452
|
+
`source: "${meta.source}". r.storeTable() requires source: "unmanaged" (via ` +
|
|
453
|
+
`defineUnmanagedTable(), or buildEntityTableMeta(..., { source: "unmanaged" })) — ` +
|
|
454
|
+
`otherwise the migration generator will treat schema drift on this table as safe ` +
|
|
455
|
+
`to DROP+rebuild, wiping any direct-write data.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
435
458
|
// The `reason` is the marker that justifies the bypass — empty
|
|
436
459
|
// strings would defeat the audit trail. Reject early so the
|
|
437
460
|
// failure points at the feature file.
|
|
438
461
|
if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
|
|
439
462
|
throw new Error(
|
|
440
|
-
`[Feature ${name}] r.
|
|
463
|
+
`[Feature ${name}] r.storeTable("${tableName}"): options.reason must be a ` +
|
|
441
464
|
`non-empty string. The reason justifies the audit-trail bypass — ` +
|
|
442
465
|
`if you can't write one, declare data via r.entity() instead.`,
|
|
443
466
|
);
|
|
444
467
|
}
|
|
445
|
-
state.
|
|
468
|
+
state.storeTables[tableName] = {
|
|
446
469
|
name: tableName,
|
|
447
470
|
meta,
|
|
448
471
|
reason: options.reason,
|
|
@@ -22,7 +22,6 @@ import type {
|
|
|
22
22
|
PreSaveHookFn,
|
|
23
23
|
ProjectionDefinition,
|
|
24
24
|
QueryHandlerDef,
|
|
25
|
-
RawTableDef,
|
|
26
25
|
ReferenceDataDef,
|
|
27
26
|
RegistrarExtensionDef,
|
|
28
27
|
RegistrarExtensionRegistration,
|
|
@@ -30,6 +29,7 @@ import type {
|
|
|
30
29
|
ScreenDefinition,
|
|
31
30
|
SearchPayloadContributorFn,
|
|
32
31
|
SecretKeyDefinition,
|
|
32
|
+
StoreTableDef,
|
|
33
33
|
TranslationKeys,
|
|
34
34
|
TreeActionDef,
|
|
35
35
|
WorkspaceDefinition,
|
|
@@ -266,8 +266,8 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
266
266
|
return state.projectionMap;
|
|
267
267
|
},
|
|
268
268
|
|
|
269
|
-
|
|
270
|
-
return state.
|
|
269
|
+
getAllStoreTables(): ReadonlyMap<string, StoreTableDef> {
|
|
270
|
+
return state.storeTableMap;
|
|
271
271
|
},
|
|
272
272
|
|
|
273
273
|
getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
|
|
@@ -23,7 +23,7 @@ export function populateFeatureCore(state: RegistryState, feature: FeatureDefini
|
|
|
23
23
|
if (clash?.kind === "raw") {
|
|
24
24
|
throw new Error(
|
|
25
25
|
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
26
|
-
`collides with r.
|
|
26
|
+
`collides with r.storeTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
27
27
|
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
@@ -249,7 +249,7 @@ export function populateMetricsAndSecrets(state: RegistryState, feature: Feature
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
// Explicit + multi-stream projections (source-entity indexed) +
|
|
252
|
+
// Explicit + multi-stream projections (source-entity indexed) + store tables +
|
|
253
253
|
// unmanaged tables (both cross-feature-uniqueness-by-physical-name guarded).
|
|
254
254
|
export function populateProjectionsAndTables(
|
|
255
255
|
state: RegistryState,
|
|
@@ -300,15 +300,15 @@ export function populateProjectionsAndTables(
|
|
|
300
300
|
state.multiStreamProjectionFeatureMap.set(qualified, feature.name);
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
//
|
|
303
|
+
// Store tables: aggregated by feature-local short name (unprefixed —
|
|
304
304
|
// these bypass the qualified-name namespace because they have no
|
|
305
305
|
// event-stream binding to disambiguate). Reject cross-feature
|
|
306
306
|
// duplicates at boot so the dev-server doesn't race two CREATE TABLE
|
|
307
307
|
// statements that target the same physical table name. Two features
|
|
308
308
|
// registering the same physical tableName would also race two CREATE
|
|
309
309
|
// TABLE statements via migrate-runner.
|
|
310
|
-
for (const [rawName, rawDef] of Object.entries(feature.
|
|
311
|
-
const existing = state.
|
|
310
|
+
for (const [rawName, rawDef] of Object.entries(feature.storeTables ?? {})) {
|
|
311
|
+
const existing = state.storeTableMap.get(rawName);
|
|
312
312
|
if (existing) {
|
|
313
313
|
throw new Error(
|
|
314
314
|
`Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
|
|
@@ -337,7 +337,7 @@ export function populateProjectionsAndTables(
|
|
|
337
337
|
owner: rawName,
|
|
338
338
|
featureName: feature.name,
|
|
339
339
|
});
|
|
340
|
-
state.
|
|
340
|
+
state.storeTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
|
|
@@ -29,7 +29,6 @@ import type {
|
|
|
29
29
|
PreSaveHookFn,
|
|
30
30
|
ProjectionDefinition,
|
|
31
31
|
QueryHandlerDef,
|
|
32
|
-
RawTableDef,
|
|
33
32
|
ReferenceDataDef,
|
|
34
33
|
RegistrarExtensionDef,
|
|
35
34
|
RegistrarExtensionRegistration,
|
|
@@ -37,6 +36,7 @@ import type {
|
|
|
37
36
|
ScreenDefinition,
|
|
38
37
|
SearchPayloadContributorFn,
|
|
39
38
|
SecretKeyDefinition,
|
|
39
|
+
StoreTableDef,
|
|
40
40
|
TreeActionDef,
|
|
41
41
|
WorkspaceDefinition,
|
|
42
42
|
WriteHandlerDef,
|
|
@@ -199,7 +199,7 @@ export type RegistryState = {
|
|
|
199
199
|
projectionsBySource: Map<string, ProjectionDefinition[]>;
|
|
200
200
|
multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
|
|
201
201
|
multiStreamProjectionFeatureMap: Map<string, string>;
|
|
202
|
-
|
|
202
|
+
storeTableMap: Map<string, StoreTableDef>;
|
|
203
203
|
physicalTableOwners: Map<string, { kind: "entity" | "raw"; owner: string; featureName: string }>;
|
|
204
204
|
authClaimsHooks: AuthClaimsHookDef[];
|
|
205
205
|
claimKeyMap: Map<string, ClaimKeyDefinition>;
|
|
@@ -260,7 +260,7 @@ export function createInitialState(): RegistryState {
|
|
|
260
260
|
projectionsBySource: new Map(),
|
|
261
261
|
multiStreamProjectionMap: new Map(),
|
|
262
262
|
multiStreamProjectionFeatureMap: new Map(),
|
|
263
|
-
|
|
263
|
+
storeTableMap: new Map(),
|
|
264
264
|
physicalTableOwners: new Map(),
|
|
265
265
|
authClaimsHooks: [],
|
|
266
266
|
claimKeyMap: new Map(),
|
|
@@ -125,18 +125,18 @@ export type SecretKeyHandle = {
|
|
|
125
125
|
readonly name: string;
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
// ---
|
|
128
|
+
// --- Store tables (declared by features via r.storeTable()) ---
|
|
129
129
|
// Post-drizzle-cut: unified with the former r.unmanagedTable() — both
|
|
130
130
|
// carried the same reason/audit contract, differing only in whether the
|
|
131
|
-
// table value was a legacy Drizzle PgTable (
|
|
131
|
+
// table value was a legacy Drizzle PgTable (storeTable) or the framework-
|
|
132
132
|
// native EntityTableMeta (unmanagedTable, consumed by migrate-runner).
|
|
133
|
-
// EntityTableMeta was the forward-compatible shape, so
|
|
133
|
+
// EntityTableMeta was the forward-compatible shape, so storeTable adopted it.
|
|
134
134
|
|
|
135
|
-
/** Options accepted by `r.
|
|
135
|
+
/** Options accepted by `r.storeTable()`. The `reason` is required so the
|
|
136
136
|
* bypass leaves an audit trail at the registration site — reviewers can
|
|
137
137
|
* judge legitimacy without spelunking into history, and a future cleanup
|
|
138
138
|
* pass can find candidates for migration to `r.entity()`. */
|
|
139
|
-
export type
|
|
139
|
+
export type StoreTableOptions = {
|
|
140
140
|
/** Why this table needs to bypass the event-sourcing system. Examples:
|
|
141
141
|
* "imported from pre-ES system, read-only", "external Stripe webhook
|
|
142
142
|
* payload cache, write-only by webhook handler", "denormalised
|
|
@@ -149,22 +149,22 @@ export type RawTableOptions = {
|
|
|
149
149
|
readonly piiEncryptedOnWrite?: true;
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
-
/** Per-feature
|
|
152
|
+
/** Per-feature store-table registration. `meta` is the `EntityTableMeta`
|
|
153
153
|
* (framework-native shape used by `migrate-runner`). Carries the
|
|
154
154
|
* bypass-justification reason but knows nothing about the owning
|
|
155
155
|
* feature — that's added when the registry aggregates entries
|
|
156
|
-
* cross-feature into `
|
|
157
|
-
export type
|
|
156
|
+
* cross-feature into `StoreTableDef`. */
|
|
157
|
+
export type StoreTableEntry = {
|
|
158
158
|
readonly name: string;
|
|
159
159
|
readonly meta: EntityTableMeta;
|
|
160
160
|
readonly reason: string;
|
|
161
161
|
readonly piiEncryptedOnWrite?: true;
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
-
/** Registry-aggregated
|
|
165
|
-
* the owning feature name. This is what `Registry.
|
|
164
|
+
/** Registry-aggregated store-table — the per-feature `StoreTableEntry` plus
|
|
165
|
+
* the owning feature name. This is what `Registry.getAllStoreTables()`
|
|
166
166
|
* exposes to readers (dev-server, ops UIs). */
|
|
167
|
-
export type
|
|
167
|
+
export type StoreTableDef = StoreTableEntry & {
|
|
168
168
|
readonly featureName: string;
|
|
169
169
|
};
|
|
170
170
|
|
|
@@ -341,13 +341,13 @@ export type FeatureDefinition = {
|
|
|
341
341
|
// den Hono-app (außerhalb /api/*). Pattern symmetrisch zu queryHandlers/
|
|
342
342
|
// writeHandlers — Routes leben mit dem Feature, nicht im Bootstrap.
|
|
343
343
|
readonly httpRoutes: Readonly<Record<string, HttpRouteDefinition>>;
|
|
344
|
-
//
|
|
344
|
+
// Store tables declared via r.storeTable() — bypass the event-sourcing
|
|
345
345
|
// system. Keyed by feature-local short name (derived from
|
|
346
346
|
// meta.tableName). The registry attaches featureName on aggregation,
|
|
347
|
-
// lifting
|
|
347
|
+
// lifting StoreTableEntry → StoreTableDef. `kumiko schema generate`
|
|
348
348
|
// aggregates these alongside r.entity()-derived metas to build the
|
|
349
349
|
// full schema.
|
|
350
|
-
readonly
|
|
350
|
+
readonly storeTables: Readonly<Record<string, StoreTableEntry>>;
|
|
351
351
|
// Optional Zod-schema for env-vars this feature reads at runtime.
|
|
352
352
|
// Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
|
|
353
353
|
// this to build one app-wide schema for boot-validation + dry-run
|
|
@@ -735,7 +735,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
735
735
|
// The required `reason` string is the marker that justifies the bypass —
|
|
736
736
|
// a non-empty string is the contract. If you can't write a reason,
|
|
737
737
|
// declare data via `r.entity()` instead.
|
|
738
|
-
|
|
738
|
+
storeTable(meta: EntityTableMeta, options: StoreTableOptions): void;
|
|
739
739
|
|
|
740
740
|
// Register the tree-actions schema for this feature — a map of
|
|
741
741
|
// action-name → action-definition (with optional typed args). At-most-
|
|
@@ -894,12 +894,12 @@ export type Registry = {
|
|
|
894
894
|
getProjectionsForSource(entityName: string): readonly ProjectionDefinition[];
|
|
895
895
|
getAllProjections(): ReadonlyMap<string, ProjectionDefinition>;
|
|
896
896
|
|
|
897
|
-
// All r.
|
|
897
|
+
// All r.storeTable() registrations across all features, keyed by
|
|
898
898
|
// feature-local short name. The dev-server iterates this alongside
|
|
899
899
|
// implicit projections at boot. Cross-feature uniqueness is enforced
|
|
900
900
|
// at registry-build — duplicate names from different features fail
|
|
901
901
|
// the boot, so callers can rely on a flat keyspace.
|
|
902
|
-
|
|
902
|
+
getAllStoreTables(): ReadonlyMap<string, StoreTableDef>;
|
|
903
903
|
|
|
904
904
|
// Multi-stream projections registered via r.multiStreamProjection().
|
|
905
905
|
// Keyed by qualified name. The server wires each into the event-dispatcher
|
|
@@ -67,13 +67,13 @@ export type {
|
|
|
67
67
|
FeatureMetricType,
|
|
68
68
|
FeatureRegistrar,
|
|
69
69
|
MetricOptions,
|
|
70
|
-
RawTableDef,
|
|
71
|
-
RawTableEntry,
|
|
72
|
-
RawTableOptions,
|
|
73
70
|
Registry,
|
|
74
71
|
SecretKeyDefinition,
|
|
75
72
|
SecretKeyHandle,
|
|
76
73
|
SecretOptions,
|
|
74
|
+
StoreTableDef,
|
|
75
|
+
StoreTableEntry,
|
|
76
|
+
StoreTableOptions,
|
|
77
77
|
UiHintOption,
|
|
78
78
|
UiHints,
|
|
79
79
|
} from "./feature";
|
|
@@ -200,7 +200,7 @@ export type EnqueueProjectionRebuildDeps = {
|
|
|
200
200
|
readonly db: DbConnection;
|
|
201
201
|
readonly registry: Registry;
|
|
202
202
|
// Present + projection-rebuild job registered (jobs composed) → tracked,
|
|
203
|
-
// retryable job (read_job_runs +
|
|
203
|
+
// retryable job (read_job_runs + store_job_run_logs). Absent → inline rebuild.
|
|
204
204
|
readonly jobRunner?: JobRunner;
|
|
205
205
|
};
|
|
206
206
|
|
|
@@ -9,7 +9,7 @@ const logInfo = (msg: string): void => console.log(msg);
|
|
|
9
9
|
/**
|
|
10
10
|
* Push all implicit-projection tables — one per `r.entity()` — that the
|
|
11
11
|
* registry knows about. setupTestStack already handles explicit
|
|
12
|
-
* projections, MSPs, and `r.
|
|
12
|
+
* projections, MSPs, and `r.storeTable()` declarations in its own loop;
|
|
13
13
|
* implicit projections are the missing piece for a fresh boot.
|
|
14
14
|
*
|
|
15
15
|
* Idempotent via `tableExists` so a persistent dev DB
|
|
@@ -20,7 +20,7 @@ const logInfo = (msg: string): void => console.log(msg);
|
|
|
20
20
|
* Lives next to `setupTestStack` because both are stack-bootstrap
|
|
21
21
|
* helpers that legitimately speak the `unsafe*`-DDL layer; the
|
|
22
22
|
* Table-DDL Guard's stack/** allowlist is the single shared exemption
|
|
23
|
-
* site. Apps still declare data via `r.entity()` / `r.
|
|
23
|
+
* site. Apps still declare data via `r.entity()` / `r.storeTable()` and
|
|
24
24
|
* never call this directly.
|
|
25
25
|
*/
|
|
26
26
|
export async function pushEntityProjectionTables(
|