@cosmicdrift/kumiko-framework 0.156.3 → 0.157.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.156.3",
3
+ "version": "0.157.1",
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.156.3",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.157.1",
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.rawTable() — proves the full boot path:
2
- // defineFeature declares a raw table → setupTestStack auto-pushes it →
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.rawTable() use case:
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.rawTable(stripeWebhookCacheMeta, {
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.rawTable — DB roundtrip via setupTestStack", () => {
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 raw table with its reason and featureName", () => {
56
- const all = stack.registry.getAllRawTables();
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 rawTable lives outside the event-sourcing graph:
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 raw-table writes through
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 rawTable is idempotent — CREATE IF NOT EXISTS", async () => {
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 rawTable tables — same sources as the test-stack auto-push", () => {
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.rawTable(
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.rawTable(
49
+ r.storeTable(
50
50
  defineUnmanagedTable({
51
- tableName: "read_unit_log",
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.rawTable
63
- expect(names).toContain("read_unit_log"); // r.rawTable (second registration)
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 (read_user_sessions, read_api_tokens, mail sync/seen
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: rawTables must appear in
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 raw tables created — e.g. the bundled "sessions" feature's
7
- // read_user_sessions, breaking every login in an ephemeral test stack.
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 — rawTables", () => {
20
- test("includes a feature's rawTable as a table source", () => {
19
+ describe("enumerateFeatureTableSources — storeTables", () => {
20
+ test("includes a feature's storeTable as a table source", () => {
21
21
  const feature = defineFeature("probe", (r) => {
22
- r.rawTable(probeMeta, { reason: "direct-write store" });
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 rawTable contributes nothing extra", () => {
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, rawTables, projections, multiStreamProjections) —
4
- // die frühere Template-Variante sammelte nur entities + rawTables, wodurch
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.rawTables)) {
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: `rawTable "${entry.name}" (${feature.name})`,
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.rawTable stores
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
- // - `read_delivery_attempts` — id kommt aus dem Aggregate-Stream
436
- // - `read_job_run_logs` — child-table, serial PK, kein tenant-scope
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.rawTable).
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.rawTables)) {
33
- sources.push({ table: raw.meta, origin: `rawTable "${name}" (${feature.name})` });
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.rawTable(meta, ` +
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.rawTable() — declaration-time validation + registry
2
- // aggregation. Post-drizzle-cut merge of the former r.rawTable() (legacy
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__/raw-table.integration.test.ts.
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.rawTable — declaration", () => {
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.rawTable(badMeta, { reason: "test" });
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.rawTable(probeMeta, { reason: "test" });
44
- r.rawTable(probeMeta, { reason: "test" });
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.rawTable(probeMeta, { reason: "" });
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.rawTable(probeMeta, { reason: " " });
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.rawTable(probeMeta, {
80
+ r.storeTable(probeMeta, {
68
81
  reason: "read-side projection of an event-stream",
69
82
  });
70
83
  });
71
- expect(feature.rawTables).toHaveProperty("rt_probe");
72
- expect(feature.rawTables["rt_probe"]?.reason).toBe("read-side projection of an event-stream");
73
- expect(feature.rawTables["rt_probe"]?.meta).toBe(probeMeta);
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 raw tables on one feature register under their tableName", () => {
89
+ test("two store tables on one feature register under their tableName", () => {
77
90
  const feature = defineFeature("dual", (r) => {
78
- r.rawTable(probeMeta, { reason: "one" });
79
- r.rawTable(probeMetaTwo, { reason: "two" });
91
+ r.storeTable(probeMeta, { reason: "one" });
92
+ r.storeTable(probeMetaTwo, { reason: "two" });
80
93
  });
81
- expect(Object.keys(feature.rawTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
94
+ expect(Object.keys(feature.storeTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
82
95
  });
83
96
 
84
- test("absent rawTables on a feature is ok", () => {
97
+ test("absent storeTables on a feature is ok", () => {
85
98
  const feat = defineFeature("plain", () => {
86
- // no r.rawTable calls
99
+ // no r.storeTable calls
87
100
  });
88
- expect(feat.rawTables).toEqual({});
101
+ expect(feat.storeTables).toEqual({});
89
102
  });
90
103
  });
91
104
 
92
- describe("createRegistry — rawTable aggregation", () => {
93
- test("aggregates raw tables across features and tags featureName", () => {
105
+ describe("createRegistry — storeTable aggregation", () => {
106
+ test("aggregates store tables across features and tags featureName", () => {
94
107
  const featA = defineFeature("billing", (r) => {
95
- r.rawTable(probeMeta, { reason: "external API cache" });
108
+ r.storeTable(probeMeta, { reason: "external API cache" });
96
109
  });
97
110
  const featB = defineFeature("inventory", (r) => {
98
- r.rawTable(probeMetaTwo, { reason: "imported pre-ES" });
111
+ r.storeTable(probeMetaTwo, { reason: "imported pre-ES" });
99
112
  });
100
113
 
101
114
  const registry = createRegistry([featA, featB]);
102
- const all = registry.getAllRawTables();
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.rawTable(probeMeta, { reason: "first" });
127
+ r.storeTable(probeMeta, { reason: "first" });
115
128
  });
116
129
  const featB = defineFeature("b", (r) => {
117
- r.rawTable(probeMeta, { reason: "second" });
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.rawTable(probeMeta, { reason: "first" });
139
+ r.storeTable(probeMeta, { reason: "first" });
127
140
  });
128
141
  const featB = defineFeature("b", (r) => {
129
- r.rawTable(probeMetaTwo, { reason: "second" });
142
+ r.storeTable(probeMetaTwo, { reason: "second" });
130
143
  });
131
144
  expect(() => createRegistry([featA, featB])).not.toThrow();
132
145
  });
133
146
 
134
- test("rejects a raw-table that collides with an entity's physical name", () => {
135
- const widget = createEntity({ fields: { name: createTextField() } });
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.rawTable(clashing, { reason: "clash" });
163
+ r.storeTable(clashing, { reason: "clash" });
147
164
  });
148
165
 
149
- // Entity registered first, then the colliding raw table.
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: raw table registered first, then the entity.
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.rawTable\\("${physical}"\\)`),
173
+ new RegExp(`Entity "widget".*collides with r.storeTable\\("${physical}"\\)`),
157
174
  );
158
175
  });
159
176
  });
160
177
 
161
- describe("createRegistry — raw tables with PII-annotated fields (#820)", () => {
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 raw table without piiEncryptedOnWrite", () => {
192
+ test("rejects a PII-carrying store table without piiEncryptedOnWrite", () => {
176
193
  const feat = defineFeature("probe", (r) => {
177
- r.rawTable(piiMeta, { reason: "direct-write store" });
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.rawTable(piiMeta, {
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 raw table needs no declaration", () => {
211
+ test("a PII-free store table needs no declaration", () => {
195
212
  const feat = defineFeature("probe", (r) => {
196
- r.rawTable(probeMeta, { reason: "plain store" });
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
- rawTables: state.rawTables,
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: ["rawTable", "job"],
72
+ errorMethodNames: ["storeTable", "job"],
73
73
  },
74
74
  {
75
75
  path: "packages/bundled-features/src/auth-email-password/feature.ts",
@@ -60,7 +60,7 @@ export {
60
60
  extractEnvSchema,
61
61
  extractExposesApi,
62
62
  extractExtendsRegistrar,
63
- extractRawTable,
63
+ extractStoreTable,
64
64
  extractUsesApi,
65
65
  } from "./round5";
66
66
  export { extractTreeActions } from "./round6";
@@ -95,7 +95,7 @@ export function extractExposesApi(
95
95
  });
96
96
  }
97
97
 
98
- export function extractRawTable(
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
- "rawTable",
107
+ "storeTable",
108
108
  sourceLocationFromNode(call, sourceFile),
109
- "rawTable meta is a factory/identifier argument, not a static literal",
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 "rawTable":
472
- return extractRawTable(call, sourceFile);
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
- rawTables: Record<string, RawTableEntry>;
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
- rawTables: {},
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
- rawTable(meta: EntityTableMeta, options: RawTableOptions): void {
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.rawTables[tableName]) {
429
+ if (state.storeTables[tableName]) {
430
430
  throw new Error(
431
- `[Feature ${name}] r.rawTable("${tableName}") already registered. ` +
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.rawTable("${tableName}"): options.reason must be a ` +
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.rawTables[tableName] = {
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
- getAllRawTables(): ReadonlyMap<string, RawTableDef> {
270
- return state.rawTableMap;
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.rawTable("${physical}") (feature "${clash.featureName}"). ` +
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) + raw tables +
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
- // Raw tables: aggregated by feature-local short name (unprefixed —
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.rawTables ?? {})) {
311
- const existing = state.rawTableMap.get(rawName);
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.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
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
- rawTableMap: Map<string, RawTableDef>;
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
- rawTableMap: new Map(),
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
- // --- Raw tables (declared by features via r.rawTable()) ---
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 (rawTable) or the framework-
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 rawTable adopted it.
133
+ // EntityTableMeta was the forward-compatible shape, so storeTable adopted it.
134
134
 
135
- /** Options accepted by `r.rawTable()`. The `reason` is required so the
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 RawTableOptions = {
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 raw-table registration. `meta` is the `EntityTableMeta`
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 `RawTableDef`. */
157
- export type RawTableEntry = {
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 raw-table — the per-feature `RawTableEntry` plus
165
- * the owning feature name. This is what `Registry.getAllRawTables()`
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 RawTableDef = RawTableEntry & {
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
- // Raw tables declared via r.rawTable() — bypass the event-sourcing
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 RawTableEntryRawTableDef. `kumiko schema generate`
347
+ // lifting StoreTableEntryStoreTableDef. `kumiko schema generate`
348
348
  // aggregates these alongside r.entity()-derived metas to build the
349
349
  // full schema.
350
- readonly rawTables: Readonly<Record<string, RawTableEntry>>;
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
- rawTable(meta: EntityTableMeta, options: RawTableOptions): void;
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.rawTable() registrations across all features, keyed by
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
- getAllRawTables(): ReadonlyMap<string, RawTableDef>;
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 + read_job_run_logs). Absent → inline rebuild.
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.rawTable()` declarations in its own loop;
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.rawTable()` and
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(