@cosmicdrift/kumiko-bundled-features 0.156.2 → 0.157.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +6 -6
  2. package/src/auth-mfa/__tests__/verify.integration.test.ts +17 -19
  3. package/src/auth-mfa/handlers/verify.write.ts +1 -0
  4. package/src/cap-counter/__tests__/entity-ddl.test.ts +15 -0
  5. package/src/cap-counter/entity.ts +2 -2
  6. package/src/delivery/feature.ts +2 -2
  7. package/src/delivery/tables.ts +2 -2
  8. package/src/feature-toggles/feature.ts +1 -1
  9. package/src/feature-toggles/global-feature-state-table.ts +1 -1
  10. package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
  11. package/src/inbound-mail-foundation/entities.ts +8 -4
  12. package/src/inbound-mail-foundation/feature.ts +3 -3
  13. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +2 -2
  14. package/src/inbound-mail-foundation/retention-sweep.ts +1 -1
  15. package/src/inbound-mail-foundation/types.ts +1 -1
  16. package/src/inbound-mail-foundation/watch-supervisor.ts +1 -1
  17. package/src/jobs/__tests__/entity-ddl.test.ts +15 -0
  18. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +107 -0
  19. package/src/jobs/feature.ts +13 -2
  20. package/src/jobs/handlers/reindex-entity.job.ts +49 -0
  21. package/src/jobs/job-run-table.ts +3 -3
  22. package/src/personal-access-tokens/feature.ts +3 -3
  23. package/src/personal-access-tokens/schema/api-token.ts +6 -4
  24. package/src/secrets/__tests__/rotate.integration.test.ts +23 -1
  25. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +4 -4
  26. package/src/sessions/db/queries/cleanup.ts +2 -2
  27. package/src/sessions/feature.ts +5 -5
  28. package/src/sessions/schema/user-session.ts +4 -2
  29. package/src/tenant-lifecycle/stages.ts +1 -1
  30. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +6 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.156.2",
3
+ "version": "0.157.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -116,11 +116,11 @@
116
116
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
117
117
  },
118
118
  "dependencies": {
119
- "@cosmicdrift/kumiko-dispatcher-live": "0.156.2",
120
- "@cosmicdrift/kumiko-framework": "0.156.2",
121
- "@cosmicdrift/kumiko-headless": "0.156.2",
122
- "@cosmicdrift/kumiko-renderer": "0.156.2",
123
- "@cosmicdrift/kumiko-renderer-web": "0.156.2",
119
+ "@cosmicdrift/kumiko-dispatcher-live": "0.157.0",
120
+ "@cosmicdrift/kumiko-framework": "0.157.0",
121
+ "@cosmicdrift/kumiko-headless": "0.157.0",
122
+ "@cosmicdrift/kumiko-renderer": "0.157.0",
123
+ "@cosmicdrift/kumiko-renderer-web": "0.157.0",
124
124
  "@mollie/api-client": "^4.5.0",
125
125
  "imapflow": "^1.3.3",
126
126
  "mailparser": "^3.9.8",
@@ -90,15 +90,26 @@ async function enableMfaFor(idSeed: number): Promise<{
90
90
  { setupToken: start.setupToken, code: currentTotpCode(secret) },
91
91
  user,
92
92
  );
93
- // verify.write.ts re-checks tenant membership the same way login.write.ts
94
- // does without a seeded row here every verify would now hit the
95
- // membership-revoked gate below instead of the scenario each test means
96
- // to exercise.
93
+ // verify.write.ts re-checks tenant membership AND the user's own row
94
+ // (status gate) the way login.write.ts does auth-mfa never writes a
95
+ // read_users row itself (enableStart/enableConfirm only touch user-mfa),
96
+ // so both need seeding here, not just per-test where a scenario cares.
97
97
  await seedRow(stack.db, tenantMembershipsTable, {
98
98
  userId: user.id,
99
99
  tenantId: user.tenantId,
100
100
  roles: JSON.stringify(["User"]),
101
101
  });
102
+ await seedRow(stack.db, userTable, {
103
+ id: user.id,
104
+ tenantId: user.tenantId,
105
+ email: `user-${user.id}@example.com`,
106
+ passwordHash: "h",
107
+ displayName: `Test User ${idSeed}`,
108
+ locale: "de",
109
+ emailVerified: true,
110
+ roles: "[]",
111
+ status: USER_STATUS.Active,
112
+ });
102
113
  return { user, secret, recoveryCodes: start.recoveryCodes };
103
114
  }
104
115
 
@@ -247,21 +258,8 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
247
258
  const { user, secret } = await enableMfaFor(6);
248
259
  const challengeToken = challengeFor(user.id, user.tenantId);
249
260
 
250
- // auth-mfa never writes a read_users row itself (enableMfaFor only
251
- // drives enable-start/enable-confirm) seed one so the status gate has
252
- // a row to actually flip, then flip it to simulate a restriction landing
253
- // between login and verify.
254
- await seedRow(stack.db, userTable, {
255
- id: user.id,
256
- tenantId: user.tenantId,
257
- email: `user-${user.id}@example.com`,
258
- passwordHash: "h",
259
- displayName: "Restricted User",
260
- locale: "de",
261
- emailVerified: true,
262
- roles: "[]",
263
- status: USER_STATUS.Active,
264
- });
261
+ // enableMfaFor already seeded the user row Active flip it to simulate
262
+ // a restriction landing between login and verify.
265
263
  await asRawClient(stack.db).unsafe(
266
264
  `UPDATE "${userTable.tableName}" SET status = $1 WHERE id = $2`,
267
265
  [USER_STATUS.Restricted, user.id],
@@ -139,6 +139,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
139
139
  // now. Deliberately NOT re-checking strictEmailVerification here:
140
140
  // MfaVerifyOptions has no such flag, and email verification can't
141
141
  // regress between login and verify within the 10-minute window.
142
+ if (!userRow) return invalidChallengeToken();
142
143
  if (
143
144
  userRow?.status === USER_STATUS.Restricted ||
144
145
  userRow?.status === USER_STATUS.DeletionRequested ||
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
+ import { capCounterEntity } from "../entity";
4
+
5
+ function pgTypeOf(table: unknown, dbName: string): string | undefined {
6
+ const cols = (table as { columns?: ReadonlyArray<{ name: string; pgType?: string }> }).columns;
7
+ return cols?.find((c) => c.name === dbName)?.pgType;
8
+ }
9
+
10
+ describe("capCounterEntity — DDL (#1205 regression)", () => {
11
+ test("value column is bigint, not double precision", () => {
12
+ const table = buildEntityTable("cap-counter", capCounterEntity);
13
+ expect(pgTypeOf(table, "value")).toBe("bigint");
14
+ });
15
+ });
@@ -1,6 +1,6 @@
1
1
  import {
2
+ createBigIntField,
2
3
  createEntity,
3
- createNumberField,
4
4
  createTextField,
5
5
  createTimestampField,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -41,7 +41,7 @@ export const capCounterEntity = createEntity({
41
41
  table: "read_cap_counters",
42
42
  fields: {
43
43
  capName: createTextField({ required: true, maxLength: 100, sortable: true, searchable: true }),
44
- value: createNumberField({ required: true, default: 0 }),
44
+ value: createBigIntField({ required: true, default: 0 }),
45
45
  periodStart: createTimestampField({ required: true }),
46
46
  lastSoftWarnedAt: createTimestampField(),
47
47
  },
@@ -22,7 +22,7 @@ import {
22
22
  export function createDeliveryFeature(): FeatureDefinition {
23
23
  return defineFeature("delivery", (r) => {
24
24
  r.describe(
25
- "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `read_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
25
+ "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
26
26
  );
27
27
  r.uiHints({
28
28
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -36,7 +36,7 @@ export function createDeliveryFeature(): FeatureDefinition {
36
36
  r.entity("notification-preference", notificationPreferenceEntity, {
37
37
  table: notificationPreferencesTable,
38
38
  });
39
- r.rawTable(deliveryAttemptsTableMeta, {
39
+ r.storeTable(deliveryAttemptsTableMeta, {
40
40
  reason: "read_side.delivery_attempt_log",
41
41
  });
42
42
 
@@ -28,7 +28,7 @@ import {
28
28
  // PK = event aggregate-id (uuid). Keeps the projection row linked back to
29
29
  // its event stream 1:1 — same convention as jobRunsTable + tenantSecretsTable.
30
30
  // Event replays stay idempotent (primary-key conflict instead of duplicate rows).
31
- export const deliveryAttemptsTable = pgTable("read_delivery_attempts", {
31
+ export const deliveryAttemptsTable = pgTable("store_delivery_attempts", {
32
32
  id: uuid("id").primaryKey(),
33
33
  tenantId: uuid("tenant_id").notNull(),
34
34
  notificationType: text("notification_type").notNull(),
@@ -53,7 +53,7 @@ export const deliveryAttemptsTable = pgTable("read_delivery_attempts", {
53
53
  // pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
54
54
  // aus dieser Meta ab.
55
55
  export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
56
- tableName: "read_delivery_attempts",
56
+ tableName: "store_delivery_attempts",
57
57
  columns: [
58
58
  { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
59
59
  { name: "tenant_id", pgType: "uuid", notNull: true },
@@ -39,7 +39,7 @@ export function createFeatureTogglesFeature(
39
39
  ): FeatureDefinition {
40
40
  return defineFeature("feature-toggles", (r) => {
41
41
  r.describe(
42
- 'Persists per-feature enabled/disabled state in the `read_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
42
+ 'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
43
43
  );
44
44
  r.uiHints({
45
45
  displayLabel: "Feature Toggles · Operator Switches",
@@ -15,7 +15,7 @@ import {
15
15
  // name IS the identity here. No tenantId: this is a global override that
16
16
  // applies across every tenant (per-tenant toggles are intentionally out of
17
17
  // scope, see core-feature-toggles.md).
18
- export const globalFeatureStateTable = pgTable("read_global_feature_state", {
18
+ export const globalFeatureStateTable = pgTable("store_global_feature_state", {
19
19
  featureName: text("feature_name").primaryKey(),
20
20
  enabled: boolean("enabled").notNull(),
21
21
  // Optimistic-lock column. The set-handler reads the existing row, then
@@ -44,7 +44,7 @@ describe("inboundMailFoundationFeature — shape", () => {
44
44
  test("SyncCursor + SeenMessage sind unmanaged (NICHT event-sourced, Plan §3.4)", () => {
45
45
  // Drift-Pin: als r.entity würden die Tick-State-Tabellen (a) das
46
46
  // Event-Log fluten und (b) beim Projection-Rebuild gewischt.
47
- const unmanaged = Object.keys(inboundMailFoundationFeature.rawTables);
47
+ const unmanaged = Object.keys(inboundMailFoundationFeature.storeTables);
48
48
  expect(unmanaged.length).toBe(2);
49
49
  });
50
50
  });
@@ -111,7 +111,7 @@ export const MAIL_THREAD_PII_FIELDS = collectPiiSubjectFields(mailThreadEntity);
111
111
  * Write-locked auf privileged — nur Foundation-Handler/Supervisor
112
112
  * schreiben, kein Tenant-Request. */
113
113
  export const syncCursorEntity = createEntity({
114
- table: "read_mail_sync_cursors",
114
+ table: "store_mail_sync_cursors",
115
115
  softDelete: false,
116
116
  fields: {
117
117
  accountId: createTextField({
@@ -141,7 +141,7 @@ export const syncCursorEntity = createEntity({
141
141
  * ingest-handler prüft zuerst hier (cheap), der Stream-Scan bleibt
142
142
  * Defense-in-depth. */
143
143
  export const seenMessageEntity = createEntity({
144
- table: "read_mail_seen_messages",
144
+ table: "store_mail_seen_messages",
145
145
  softDelete: false,
146
146
  fields: {
147
147
  accountId: createTextField({
@@ -164,5 +164,9 @@ export const seenMessageEntity = createEntity({
164
164
  // Plain EntityTableMeta (kein branded EntityTable) — unmanaged Direct-
165
165
  // Write-Stores, Handler schreiben via ctx.db (siehe user-session.ts-
166
166
  // Rationale).
167
- export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity);
168
- export const seenMessageTable = buildEntityTableMeta("mail-seen-message", seenMessageEntity);
167
+ export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity, {
168
+ source: "unmanaged",
169
+ });
170
+ export const seenMessageTable = buildEntityTableMeta("mail-seen-message", seenMessageEntity, {
171
+ source: "unmanaged",
172
+ });
@@ -156,12 +156,12 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
156
156
  });
157
157
 
158
158
  // Sync-Maschinerie: hochfrequenter Tick-State, bewusst NICHT
159
- // event-sourced — r.rawTable hält die Migration-DDL, nimmt die
159
+ // event-sourced — r.storeTable hält die Migration-DDL, nimmt die
160
160
  // Tabellen aber aus dem Projection-Rebuild (#494/#498-Klasse).
161
- r.rawTable(syncCursorTable, {
161
+ r.storeTable(syncCursorTable, {
162
162
  reason: "read_side.mail_sync_cursors_direct_write",
163
163
  });
164
- r.rawTable(seenMessageTable, {
164
+ r.storeTable(seenMessageTable, {
165
165
  reason: "read_side.mail_seen_messages_direct_write",
166
166
  });
167
167
 
@@ -2,7 +2,7 @@
2
2
  // (watch-callback + fetchSince-Sync). Nimmt eine normalisierte
3
3
  // RawInboundMessage und macht atomic in EINER TX:
4
4
  //
5
- // 1. Idempotency-Check (cheap: read_mail_seen_messages; Defense-in-
5
+ // 1. Idempotency-Check (cheap: store_mail_seen_messages; Defense-in-
6
6
  // depth: Stream-Existenz auf der deterministic aggregateId).
7
7
  // 2. threadKey-Normalisierung (Provider-Thread-ID bevorzugt, sonst
8
8
  // References-Chain-Root, sonst Message-ID = Single-Message-Thread).
@@ -115,7 +115,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
115
115
  // ein früherer ingest NACH dem append aber VOR dem seen-insert
116
116
  // gestorben ist (kann in einer TX nicht passieren — aber der
117
117
  // Check ist billig und macht den Handler rebuild-robust falls
118
- // read_mail_seen_messages je getruncated wird).
118
+ // store_mail_seen_messages je getruncated wird).
119
119
  // ---------------------------------------------------------------
120
120
  const seenRows = await selectMany(ctx.db.raw, seenMessageTable, {
121
121
  accountId: payload.accountId,
@@ -13,7 +13,7 @@
13
13
  // loadAggregate leer → received wird gar nicht erst repliziert. Gleiches
14
14
  // Muster wie tenant-destroy-hook, nur per-Row-cutoff statt ganzer Tenant.
15
15
  //
16
- // read_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
16
+ // store_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
17
17
  // Dedup-Anker braucht nur das Backfill-/Replay-Fenster → seenAt-cutoff
18
18
  // via plain deleteMany, kein Stream/Archiv. Gescoped direkt über die
19
19
  // tenant_id-Spalte der Tabelle (table-builder-Konvention), kein Account-Join.
@@ -92,7 +92,7 @@ export type RawInboundMessage = {
92
92
 
93
93
  /** Provider-opaker Cursor: IMAP {uidValidity,lastUid} · Graph {deltaLink}
94
94
  * · Gmail {historyId}. Foundation persisted ihn JSON-stringified in
95
- * read_mail_sync_cursors, interpretiert ihn nie. */
95
+ * store_mail_sync_cursors, interpretiert ihn nie. */
96
96
  export type SyncCursorPayload = Readonly<Record<string, unknown>>;
97
97
 
98
98
  export type InboundFetchResult = {
@@ -63,7 +63,7 @@ export type InboundMailSupervisorDeps = {
63
63
  * Credential-Reads der Provider). */
64
64
  readonly providerCtx: InboundMailContext;
65
65
  /** App-DB — direct reads auf read_mail_accounts (Account-Snapshot)
66
- * + direct writes auf read_mail_sync_cursors (unmanaged store). */
66
+ * + direct writes auf store_mail_sync_cursors (unmanaged store). */
67
67
  readonly db: DbConnection;
68
68
  /** Standard-Dispatcher mit SystemUser — trägt ingest-message +
69
69
  * update-account. */
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
+ import { jobRunEntity } from "../job-run-table";
4
+
5
+ function pgTypeOf(table: unknown, dbName: string): string | undefined {
6
+ const cols = (table as { columns?: ReadonlyArray<{ name: string; pgType?: string }> }).columns;
7
+ return cols?.find((c) => c.name === dbName)?.pgType;
8
+ }
9
+
10
+ describe("jobRunEntity — DDL (#1205 regression)", () => {
11
+ test("duration column is integer, not double precision", () => {
12
+ const table = buildEntityTable("read_job_runs", jobRunEntity);
13
+ expect(pgTypeOf(table, "duration")).toBe("integer");
14
+ });
15
+ });
@@ -0,0 +1,107 @@
1
+ // #1215: the `jobs` feature registers `reindexEntity` as a manual + perTenant
2
+ // job. perTenant jobs fan out through a BullMQ wrapper that needs
3
+ // `getActiveTenantIds` (job-runner.ts) — setupTestStack's jobRunner doesn't
4
+ // wire that, so this calls the exported handler directly with a constructed
5
+ // context instead of going through jobRunner.dispatch(), same as
6
+ // inbound-mail-foundation's retention job test calls its sweep function
7
+ // directly rather than round-tripping through the queue.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import {
11
+ buildEntityTable,
12
+ createEventStoreExecutor,
13
+ createTenantDb,
14
+ } from "@cosmicdrift/kumiko-framework/db";
15
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ TestUsers,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { createJobsFeature } from "../feature";
24
+ import { reindexEntityJob } from "../handlers/reindex-entity.job";
25
+
26
+ // Resolved job name — operators trigger this via jobs:write:trigger with
27
+ // { jobName: "jobs:job:reindex-entity", payload: { entity: "..." } }.
28
+ const REINDEX_ENTITY_JOB = "jobs:job:reindex-entity";
29
+
30
+ const widgetEntity = createEntity({
31
+ table: "read_reindex_job_widgets",
32
+ fields: {
33
+ name: createTextField({ required: true, searchable: true }),
34
+ },
35
+ });
36
+
37
+ const widgetTable = buildEntityTable("widget", widgetEntity);
38
+
39
+ const widgetFeature = defineFeature("reindex-job-test", (r) => {
40
+ r.entity("widget", widgetEntity);
41
+ });
42
+
43
+ let stack: TestStack;
44
+ const admin = TestUsers.admin;
45
+
46
+ beforeAll(async () => {
47
+ stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
48
+ await unsafeCreateEntityTable(stack.db, widgetEntity);
49
+ await createEventsTable(stack.db);
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await stack.cleanup();
54
+ });
55
+
56
+ describe("jobs feature: reindexEntity registration", () => {
57
+ test("registers reindexEntity as jobs:job:reindex-entity — the name jobs:write:trigger dispatches by", () => {
58
+ const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
59
+ expect(job).toBeDefined();
60
+ expect(job?.handler).toBe(reindexEntityJob);
61
+ expect(job?.perTenant).toBe(true);
62
+ expect(job?.trigger).toEqual({ manual: true });
63
+ });
64
+ });
65
+
66
+ describe("reindexEntityJob", () => {
67
+ test("indexes rows for the tenant resolved from ctx.systemUser", async () => {
68
+ const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
69
+ const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
70
+ const created = await executor.create({ name: "Job Backfillable Widget" }, admin, tenantDb);
71
+ if (!created.isSuccess) throw new Error("seed failed");
72
+
73
+ // No stack.eventDispatcher.runOnce() — row was never indexed live.
74
+ const preResults = await stack.search.search(admin.tenantId, "backfillable", {
75
+ filterType: "widget",
76
+ });
77
+ expect(preResults).toHaveLength(0);
78
+
79
+ await reindexEntityJob(
80
+ { entity: "widget" },
81
+ { db: stack.db, registry: stack.registry, searchAdapter: stack.search, systemUser: admin },
82
+ );
83
+
84
+ const postResults = await stack.search.search(admin.tenantId, "backfillable", {
85
+ filterType: "widget",
86
+ });
87
+ expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
88
+ });
89
+
90
+ test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
91
+ await expect(
92
+ reindexEntityJob(
93
+ { entity: "widget" },
94
+ { db: stack.db, registry: stack.registry, searchAdapter: stack.search },
95
+ ),
96
+ ).resolves.toBeUndefined();
97
+ });
98
+
99
+ test("throws when ctx.searchAdapter is missing", async () => {
100
+ await expect(
101
+ reindexEntityJob(
102
+ { entity: "widget" },
103
+ { db: stack.db, registry: stack.registry, systemUser: admin },
104
+ ),
105
+ ).rejects.toThrow(/searchAdapter/);
106
+ });
107
+ });
@@ -17,6 +17,7 @@ import {
17
17
  projectionRebuildJob,
18
18
  projectionRebuildPayloadSchema,
19
19
  } from "./handlers/projection-rebuild.job";
20
+ import { reindexEntityJob, reindexEntityPayloadSchema } from "./handlers/reindex-entity.job";
20
21
  import { retryWrite } from "./handlers/retry.write";
21
22
  import { triggerWrite } from "./handlers/trigger.write";
22
23
  import { JOBS_I18N } from "./i18n";
@@ -30,7 +31,7 @@ import { jobRunLogsTable, jobRunLogsTableMeta, jobRunsTable } from "./job-run-ta
30
31
  export function createJobsFeature(): FeatureDefinition {
31
32
  return defineFeature("jobs", (r) => {
32
33
  r.describe(
33
- "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `read_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:details` for the operator UI.",
34
+ "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:details` for the operator UI.",
34
35
  );
35
36
  r.uiHints({
36
37
  displayLabel: "Jobs · Audit & Operator UI",
@@ -38,7 +39,7 @@ export function createJobsFeature(): FeatureDefinition {
38
39
  recommended: false,
39
40
  });
40
41
  r.systemScope();
41
- r.rawTable(jobRunLogsTableMeta, {
42
+ r.storeTable(jobRunLogsTableMeta, {
42
43
  reason: "read_side.job_run_logs",
43
44
  });
44
45
  // Events-only aggregate: "jobRun" has no r.entity registration, because
@@ -174,6 +175,16 @@ export function createJobsFeature(): FeatureDefinition {
174
175
  projectionRebuildJob,
175
176
  );
176
177
 
178
+ // Retroactive search backfill (#1206/#1215) — manual + perTenant, so one
179
+ // `jobs:write:trigger` call with { entity } fans out to every active
180
+ // tenant (job-runner.ts perTenant dispatch applies to manual triggers
181
+ // too, not just cron).
182
+ r.job(
183
+ "reindexEntity",
184
+ { trigger: { manual: true }, perTenant: true, schema: reindexEntityPayloadSchema },
185
+ reindexEntityJob,
186
+ );
187
+
177
188
  const handlers = {
178
189
  trigger: r.writeHandler(triggerWrite),
179
190
  retry: r.writeHandler(retryWrite),
@@ -0,0 +1,49 @@
1
+ // Manual, perTenant backfill job (#1206/#1215) — indexes existing rows for
2
+ // an entity that only got `searchable: true` after rows already existed.
3
+ // perTenant fan-out (job-runner.ts) also applies to a manual dispatch, not
4
+ // just cron: one `jobs:write:trigger` call with { entity } re-runs once per
5
+ // active tenant.
6
+
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
11
+ import { z } from "zod";
12
+
13
+ export const reindexEntityPayloadSchema = z.object({
14
+ entity: z.string().min(1),
15
+ });
16
+
17
+ export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
18
+ const { entity } = reindexEntityPayloadSchema.parse(rawPayload);
19
+ if (!ctx.db) {
20
+ throw new InternalError({
21
+ message: "[jobs:reindex-entity] ctx.db missing — job context requires a database connection.",
22
+ });
23
+ }
24
+ if (!ctx.registry) {
25
+ throw new InternalError({
26
+ message: "[jobs:reindex-entity] ctx.registry missing — job context requires the registry.",
27
+ });
28
+ }
29
+ if (!ctx.searchAdapter) {
30
+ throw new InternalError({
31
+ message:
32
+ "[jobs:reindex-entity] ctx.searchAdapter missing — mount a search adapter to run this job.",
33
+ });
34
+ }
35
+ // perTenant fan-out — the job-runner enqueues one child run per active
36
+ // tenant with _tenantId set; a run without one (e.g. misconfigured
37
+ // manual dispatch outside the fan-out) has nothing scoped to reindex.
38
+ const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
39
+ if (tenantId === undefined) {
40
+ // skip: fired without a perTenant fan-out tenant — nothing scoped to reindex
41
+ return;
42
+ }
43
+ const db = ctx.db as DbConnection; // @cast-boundary db-operator
44
+ const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
45
+ ctx.log?.info?.(
46
+ `[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +
47
+ `${result.failures.length > 0 ? ` (${result.failures.length} failed)` : ""}`,
48
+ );
49
+ };
@@ -48,7 +48,7 @@ export const jobRunEntity = createEntity({
48
48
  attempt: createNumberField({ required: true, default: 1, integer: true }),
49
49
  startedAt: createTimestampField({ required: true }),
50
50
  finishedAt: createTimestampField(),
51
- duration: createNumberField(),
51
+ duration: createNumberField({ integer: true }),
52
52
  triggeredById: createTextField(),
53
53
  },
54
54
  });
@@ -60,7 +60,7 @@ export const jobRunsTable = buildEntityTable("job-run", jobRunEntity);
60
60
  // uuid of the parent jobRun. Existing detail-query callers treat it as an
61
61
  // opaque identifier, so the type-switch is backward-compatible at the
62
62
  // query surface.
63
- export const jobRunLogsTable = pgTable("read_job_run_logs", {
63
+ export const jobRunLogsTable = pgTable("store_job_run_logs", {
64
64
  id: serial("id").primaryKey(),
65
65
  runId: text("run_id").notNull(),
66
66
  level: text("level").notNull().$type<JobLogLevel>(),
@@ -76,7 +76,7 @@ export const jobRunLogsTable = pgTable("read_job_run_logs", {
76
76
  // pgTable bleibt source-of-truth für Query-API; Phase 4 leitet das pgTable
77
77
  // aus dieser Meta ab.
78
78
  export const jobRunLogsTableMeta: EntityTableMeta = defineUnmanagedTable({
79
- tableName: "read_job_run_logs",
79
+ tableName: "store_job_run_logs",
80
80
  columns: [
81
81
  { name: "id", pgType: "serial", notNull: true, primaryKey: true },
82
82
  { name: "run_id", pgType: "text", notNull: true },
@@ -40,7 +40,7 @@ export function createPersonalAccessTokensFeature(
40
40
  const { scopes } = options;
41
41
  return defineFeature(PAT_FEATURE, (r) => {
42
42
  r.describe(
43
- "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
43
+ "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
44
44
  );
45
45
  r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
46
46
  // Opt-in tier-gating (mirrors ledger/tags): when set, the feature declares
@@ -50,10 +50,10 @@ export function createPersonalAccessTokensFeature(
50
50
  // Resolver reads memberships + users on every PAT request to build live
51
51
  // roles — make both boot-time deps so a mis-wiring fails validateBoot.
52
52
  r.requires("user", "tenant");
53
- // Direct-write store like read_user_sessions: create/revoke write it, the
53
+ // Direct-write store like store_user_sessions: create/revoke write it, the
54
54
  // resolver point-reads it. r.entity would make it a rebuildable projection
55
55
  // whose replay (no token events) would wipe every live token (#498/#494).
56
- r.rawTable(buildEntityTableMeta("api-token", apiTokenEntity), {
56
+ r.storeTable(buildEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
57
57
  reason: "read_side.api_tokens_direct_write",
58
58
  // create.write encrypts `name` via encryptForDirectWrite (#820).
59
59
  piiEncryptedOnWrite: true,
@@ -6,7 +6,7 @@ import {
6
6
  createTimestampField,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
 
9
- // One row per Personal Access Token. Like read_user_sessions this is a
9
+ // One row per Personal Access Token. Like store_user_sessions this is a
10
10
  // direct-write store (r.unmanagedTable): the create/revoke handlers write it and
11
11
  // the resolver point-reads it on the hot auth path. `tokenHash` is the SHA-256
12
12
  // of the plaintext (never the plaintext); the unique index on it makes the
@@ -14,7 +14,7 @@ import {
14
14
  // request can forge ownership/scope/hash by poking a field directly — the
15
15
  // handlers mutate them via ctx.db inside the pipeline.
16
16
  export const apiTokenEntity = createEntity({
17
- table: "read_api_tokens",
17
+ table: "store_api_tokens",
18
18
  // No softDelete: revocation is its own lifecycle (revokedAt timestamp), and
19
19
  // we keep revoked rows for the "your tokens" audit list.
20
20
  softDelete: false,
@@ -52,10 +52,12 @@ export const apiTokenEntity = createEntity({
52
52
  expiresAt: createTimestampField({ access: { write: access.privileged } }),
53
53
  revokedAt: createTimestampField({ access: { write: access.privileged } }),
54
54
  },
55
- indexes: [{ unique: true, columns: ["tokenHash"], name: "read_api_tokens_hash_unique" }],
55
+ indexes: [{ unique: true, columns: ["tokenHash"], name: "store_api_tokens_hash_unique" }],
56
56
  });
57
57
 
58
58
  // buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
59
59
  // the table must be a WritableTable (post ES-write-brand #742) — same as
60
60
  // sessions' userSessionTable. buildEntityTable is branded executor-only.
61
- export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity);
61
+ export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity, {
62
+ source: "unmanaged",
63
+ });
@@ -175,8 +175,16 @@ describe("rotate-job circuit-breaker", () => {
175
175
  // Happy-path counterpart to the failure tests above: a working provider
176
176
  // that actually rewraps. Run last — it's the only test that mutates the
177
177
  // shared 20-row fixture, so the earlier "stays at 20" assertions must
178
- // see it before this runs.
178
+ // see it before this runs. Enforced below, not just documented: a
179
+ // precondition check fails loud (not silently-wrong) if a test inserted
180
+ // between this and the circuit-breaker tests already touched the
181
+ // fixture — rotateJob's batch scan has no per-row exclusion/ordering
182
+ // to fall back on, so isolating this test onto a second tenant doesn't
183
+ // help (rotator would just exhaust maxFailures on the other tenant's
184
+ // still-V1 rows before ever reaching its own).
179
185
  test("successful rotation rewraps the DEK, bumps kekVersion, and preserves plaintext", async () => {
186
+ expect(await countV1Rows()).toBe(20);
187
+
180
188
  const rotator = createEnvMasterKeyProvider({
181
189
  env: {
182
190
  KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
@@ -185,6 +193,17 @@ describe("rotate-job circuit-breaker", () => {
185
193
  },
186
194
  });
187
195
 
196
+ // Snapshot pre-rotation ciphertext — rotate.job.ts's header comment
197
+ // claims "the ciphertext itself never changes, only the DEK wrapper".
198
+ // Assert that invariant below instead of only checking kekVersion/
199
+ // plaintext, which a rewrap-that-re-encrypts would also satisfy.
200
+ const preRotation = await selectMany<{ key: string; envelope: StoredEnvelope }>(
201
+ stack.db,
202
+ tenantSecretsTable,
203
+ { tenantId: admin.tenantId },
204
+ );
205
+ const ciphertextByKey = new Map(preRotation.map((row) => [row.key, row.envelope.ciphertext]));
206
+
188
207
  await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator));
189
208
 
190
209
  expect(await countV1Rows()).toBe(0);
@@ -209,6 +228,9 @@ describe("rotate-job circuit-breaker", () => {
209
228
  expect(rows).toHaveLength(20);
210
229
  for (const row of rows) {
211
230
  expect(row.kekVersion).toBe(2);
231
+ const expectedCiphertext = ciphertextByKey.get(row.key);
232
+ expect(expectedCiphertext).toBeDefined();
233
+ expect(row.envelope.ciphertext).toBe(expectedCiphertext as string);
212
234
  const expectedIndex = row.key.split("-").at(-1);
213
235
  const plaintext = await decryptValue(decodeStoredEnvelope(row.envelope), verifier);
214
236
  expect(plaintext).toBe(`secret-${expectedIndex}`);
@@ -19,7 +19,7 @@ import { createUserFeature } from "../../user/feature";
19
19
  import { createSessionsFeature } from "../feature";
20
20
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
21
21
 
22
- // read_user_sessions is a hot-path direct-write store: sessionCreator inserts
22
+ // store_user_sessions is a hot-path direct-write store: sessionCreator inserts
23
23
  // rows and the revoke handlers update them WITHOUT emitting lifecycle events.
24
24
  // If the table is registered as an r.entity, the framework makes it a
25
25
  // rebuildable implicit projection whose replay finds zero matching events and
@@ -27,7 +27,7 @@ import { userSessionEntity, userSessionTable } from "../schema/user-session";
27
27
  // session on the next projection rebuild (deploy / `schema apply`). #498/#494.
28
28
  //
29
29
  // Pre-fix both tests are RED: the implicit projection "sessions:projection:
30
- // user-session-entity" exists and rebuilding it empties read_user_sessions.
30
+ // user-session-entity" exists and rebuilding it empties store_user_sessions.
31
31
  // Post-fix (r.unmanagedTable) the table is no longer a rebuild target.
32
32
 
33
33
  const IMPLICIT_PROJECTION = "sessions:projection:user-session-entity";
@@ -48,7 +48,7 @@ afterAll(async () => {
48
48
 
49
49
  beforeEach(async () => {
50
50
  await asRawClient(testDb.db).unsafe(
51
- "TRUNCATE read_user_sessions, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE",
51
+ "TRUNCATE store_user_sessions, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE",
52
52
  );
53
53
  });
54
54
 
@@ -70,7 +70,7 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
70
70
  await updateRows(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
71
71
  }
72
72
 
73
- describe("sessions / read_user_sessions survives projection rebuild", () => {
73
+ describe("sessions / store_user_sessions survives projection rebuild", () => {
74
74
  test("is NOT registered as a rebuildable implicit projection", () => {
75
75
  const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
76
76
  expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
@@ -7,9 +7,9 @@ export async function deleteStaleSessionsBatch(
7
7
  batchSize: number,
8
8
  ): Promise<number> {
9
9
  const rows = (await asRawClient(db).unsafe(
10
- `DELETE FROM "read_user_sessions"
10
+ `DELETE FROM "store_user_sessions"
11
11
  WHERE "id" IN (
12
- SELECT "id" FROM "read_user_sessions"
12
+ SELECT "id" FROM "store_user_sessions"
13
13
  WHERE "expires_at" < now() - ($1::int * interval '1 day')
14
14
  OR "revoked_at" < now() - ($1::int * interval '1 day')
15
15
  LIMIT $2
@@ -58,7 +58,7 @@ export function bindAutoRevokeFromFeature(
58
58
  return undefined;
59
59
  }
60
60
 
61
- // The sessions feature registers the read_user_sessions table (as an
61
+ // The sessions feature registers the store_user_sessions table (as an
62
62
  // unmanaged direct-write store, NOT an r.entity — see below) and the three
63
63
  // user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
64
64
  // export a sessionCreator/sessionRevoker here — those are produced by
@@ -76,7 +76,7 @@ export function bindAutoRevokeFromFeature(
76
76
  export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
77
77
  return defineFeature("sessions", (r) => {
78
78
  r.describe(
79
- "Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
79
+ "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
80
80
  );
81
81
  r.uiHints({
82
82
  displayLabel: "Sessions · Server-side Logout",
@@ -87,16 +87,16 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
87
87
  // gate for locked accounts) — make that a boot-time dependency so a
88
88
  // sessions-without-user wiring fails validateBoot instead of 500ing live.
89
89
  r.requires("user");
90
- // read_user_sessions is a hot-path direct-write store: sessionCreator
90
+ // store_user_sessions is a hot-path direct-write store: sessionCreator
91
91
  // inserts and the revoke handlers update rows WITHOUT emitting lifecycle
92
92
  // events (the row columns ARE the audit trail). Registering it as
93
93
  // r.entity would make it a rebuildable implicit projection whose replay
94
94
  // finds zero session events and swaps an empty shadow over the live
95
95
  // table — wiping every active session on the next projection rebuild
96
- // (#498/#494). r.rawTable keeps the migration DDL but opts the
96
+ // (#498/#494). r.storeTable keeps the migration DDL but opts the
97
97
  // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
98
98
  // which are direct-write stores too.
99
- r.rawTable(buildEntityTableMeta("user-session", userSessionEntity), {
99
+ r.storeTable(buildEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
100
100
  reason: "read_side.user_sessions_direct_write",
101
101
  // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
102
102
  piiEncryptedOnWrite: true,
@@ -20,7 +20,7 @@ export const userSessionEntity = createEntity({
20
20
  // Entity-Key ist "user-session" (mit Dash), toTableName's snake-case-
21
21
  // Transform käme auf "read_user-sessions" → kein valides SQL ohne Quoting.
22
22
  // Deshalb expliziter Override auf den read_-konformen Namen.
23
- table: "read_user_sessions",
23
+ table: "store_user_sessions",
24
24
  // sid-as-PK: the sessionCreator callback generates the UUID, returns it to
25
25
  // the framework; the framework stamps it as `jti`; here the same value is
26
26
  // the row primary key. Single source of truth for the identifier.
@@ -74,4 +74,6 @@ export const userSessionEntity = createEntity({
74
74
  // handlers write it directly via ctx.db; the meta carries no executor-only
75
75
  // brand so those writes stay legal. See feature.ts for the rebuild-exclusion
76
76
  // rationale (#494/#498).
77
- export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity);
77
+ export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity, {
78
+ source: "unmanaged",
79
+ });
@@ -109,7 +109,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
109
109
  const db = createTenantDb(ctx.db, ctx.tenantId, "system");
110
110
  // Per-row forget() through the executor, not a bulk deleteMany: memberships
111
111
  // are an ES-managed projection (add/remove/update-roles all go through
112
- // tenantMembershipCrud), so a raw table write here is eventless — a future
112
+ // tenantMembershipCrud), so a store table write here is eventless — a future
113
113
  // projection rebuild would replay the historical add-member events and
114
114
  // resurrect rows this stage removed. forget() (Art.17 hard-purge) keeps the
115
115
  // erasure rebuild-safe and gives each membership its own audit event.
@@ -146,7 +146,7 @@ async function seedUser(id: string): Promise<void> {
146
146
  describe("user-session userData-hooks", () => {
147
147
  async function seedSession(id: string, userId: string, tenantId: string): Promise<void> {
148
148
  await asRawClient(full.db).unsafe(
149
- `INSERT INTO read_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
149
+ `INSERT INTO store_user_sessions (id, user_id, tenant_id, created_at, expires_at, ip, user_agent)
150
150
  VALUES ($1, $2, $3, now(), now() + interval '1 day', '203.0.113.7', 'TestAgent/1.0')`,
151
151
  [id, userId, tenantId],
152
152
  );
@@ -170,11 +170,11 @@ describe("user-session userData-hooks", () => {
170
170
  await userSessionDeleteHook(ctx("sess-user-2"), "delete");
171
171
 
172
172
  const a = await rawSelect(
173
- "SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
173
+ "SELECT * FROM store_user_sessions WHERE user_id = $1 AND tenant_id = $2",
174
174
  ["sess-user-2", TENANT_A],
175
175
  );
176
176
  const b = await rawSelect(
177
- "SELECT * FROM read_user_sessions WHERE user_id = $1 AND tenant_id = $2",
177
+ "SELECT * FROM store_user_sessions WHERE user_id = $1 AND tenant_id = $2",
178
178
  ["sess-user-2", TENANT_B],
179
179
  );
180
180
  expect(a).toHaveLength(0);
@@ -185,7 +185,7 @@ describe("user-session userData-hooks", () => {
185
185
  describe("api-token userData-hooks", () => {
186
186
  async function seedToken(id: string, userId: string): Promise<void> {
187
187
  await asRawClient(full.db).unsafe(
188
- `INSERT INTO read_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
188
+ `INSERT INTO store_api_tokens (id, user_id, tenant_id, name, token_hash, prefix, scopes, created_at)
189
189
  VALUES ($1, $2, $3, 'My MacBook', $4, 'kum_ab12', '["read"]', now())`,
190
190
  [id, userId, TENANT_A, `hash-${id}`],
191
191
  );
@@ -207,7 +207,7 @@ describe("api-token userData-hooks", () => {
207
207
 
208
208
  await apiTokenDeleteHook(ctx("pat-user-2"), "delete");
209
209
 
210
- const rows = await rawSelect("SELECT * FROM read_api_tokens WHERE user_id = $1", [
210
+ const rows = await rawSelect("SELECT * FROM store_api_tokens WHERE user_id = $1", [
211
211
  "pat-user-2",
212
212
  ]);
213
213
  expect(rows).toHaveLength(0);
@@ -371,7 +371,7 @@ describe("config-value userData-hooks", () => {
371
371
  describe("delivery-attempt userData-hooks (#799)", () => {
372
372
  async function seedAttempt(id: string, recipientId: string, tenantId: string): Promise<void> {
373
373
  await asRawClient(full.db).unsafe(
374
- `INSERT INTO read_delivery_attempts
374
+ `INSERT INTO store_delivery_attempts
375
375
  (id, tenant_id, notification_type, channel, recipient_id, recipient_address, status, priority)
376
376
  VALUES ($1, $2, 'app:notify:ping', 'email', $3, $4, 'sent', 'normal')`,
377
377
  [id, tenantId, recipientId, `user-${recipientId}@example.com`],