@cosmicdrift/kumiko-framework 0.113.1 → 1.0.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 (76) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/schema-cli.integration.test.ts +0 -29
  3. package/src/api/__tests__/batch.integration.test.ts +1 -4
  4. package/src/api/__tests__/request-id-middleware.test.ts +0 -51
  5. package/src/api/auth-middleware.ts +1 -65
  6. package/src/api/auth-routes.ts +0 -11
  7. package/src/api/index.ts +1 -3
  8. package/src/api/jwt.ts +4 -1
  9. package/src/api/request-context.ts +0 -3
  10. package/src/api/request-id-middleware.ts +0 -2
  11. package/src/api/routes.ts +1 -26
  12. package/src/api/server.ts +1 -29
  13. package/src/bun-db/__tests__/write-brand.test.ts +0 -21
  14. package/src/bun-db/query.ts +0 -17
  15. package/src/db/__tests__/config-seed.integration.test.ts +5 -13
  16. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
  18. package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
  19. package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
  20. package/src/db/config-seed.ts +9 -9
  21. package/src/db/encryption.ts +0 -7
  22. package/src/db/entity-field-encryption.ts +24 -81
  23. package/src/db/event-store-executor.ts +24 -37
  24. package/src/db/index.ts +0 -3
  25. package/src/db/query.ts +0 -1
  26. package/src/db/tenant-db.ts +9 -14
  27. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
  28. package/src/engine/__tests__/boot-validator.test.ts +9 -33
  29. package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
  30. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
  31. package/src/engine/__tests__/registry.test.ts +0 -28
  32. package/src/engine/__tests__/screen.test.ts +0 -40
  33. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
  34. package/src/engine/boot-validator/gdpr-storage.ts +1 -2
  35. package/src/engine/boot-validator/index.ts +2 -7
  36. package/src/engine/boot-validator/screens-nav.ts +0 -21
  37. package/src/engine/define-feature.ts +0 -17
  38. package/src/engine/define-handler.ts +2 -16
  39. package/src/engine/extensions/user-data.ts +1 -12
  40. package/src/engine/index.ts +2 -5
  41. package/src/engine/registry.ts +21 -44
  42. package/src/engine/soft-delete-cleanup.ts +2 -6
  43. package/src/engine/types/feature.ts +21 -29
  44. package/src/engine/types/fields.ts +1 -3
  45. package/src/engine/types/handlers.ts +2 -16
  46. package/src/engine/types/index.ts +0 -2
  47. package/src/engine/types/projection.ts +0 -22
  48. package/src/engine/types/screen.ts +0 -29
  49. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  50. package/src/event-store/index.ts +0 -8
  51. package/src/files/provider-resolver.ts +15 -28
  52. package/src/i18n/required-surface-keys.ts +0 -12
  53. package/src/observability/__tests__/observability.integration.test.ts +1 -4
  54. package/src/pipeline/dispatcher.ts +6 -2
  55. package/src/pipeline/msp-rebuild.ts +3 -36
  56. package/src/pipeline/projection-rebuild.ts +3 -61
  57. package/src/schema-cli.ts +15 -24
  58. package/src/secrets/__tests__/contains-secret.test.ts +0 -34
  59. package/src/secrets/dek-cache.ts +5 -26
  60. package/src/secrets/envelope.ts +3 -5
  61. package/src/secrets/index.ts +1 -13
  62. package/src/secrets/types.ts +3 -18
  63. package/src/testing/e2e-generator.ts +0 -3
  64. package/src/testing/index.ts +0 -2
  65. package/src/testing/mutable-master-key-provider.ts +3 -29
  66. package/src/time/__tests__/tz-dateline.test.ts +10 -8
  67. package/src/ui-types/index.ts +0 -1
  68. package/src/api/__tests__/pat-scope.test.ts +0 -36
  69. package/src/api/pat-scope.ts +0 -14
  70. package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
  71. package/src/event-store/rebuild-dead-letter.ts +0 -111
  72. package/src/files/__tests__/provider-resolver.test.ts +0 -70
  73. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
  74. package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
  75. package/src/secrets/envelope-cipher.ts +0 -81
  76. package/src/secrets/stored-envelope.ts +0 -46
@@ -56,11 +56,6 @@ type Primitive = string | number | boolean | bigint | symbol | null | undefined;
56
56
 
57
57
  // Opaque built-in leaves a response legitimately carries; never recurse into
58
58
  // them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
59
- // Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
60
- // already fell through to `false` via the object-mapped-type branch — listed
61
- // explicitly here so the compile-time treatment matches leak-guard.ts's
62
- // runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
63
- // instead of looking like an oversight.
64
59
  type SafeLeaf =
65
60
  | Date
66
61
  | RegExp
@@ -71,9 +66,7 @@ type SafeLeaf =
71
66
  | Temporal.PlainTime
72
67
  | Temporal.PlainYearMonth
73
68
  | Temporal.PlainMonthDay
74
- | Temporal.Duration
75
- | Map<unknown, unknown>
76
- | Set<unknown>;
69
+ | Temporal.Duration;
77
70
 
78
71
  export type ContainsSecret<T> = [T] extends [never]
79
72
  ? false
@@ -144,14 +137,6 @@ export type Envelope = {
144
137
  readonly kekVersion: number;
145
138
  };
146
139
 
147
- // BYOK hook: callers pass the tenant a value belongs to; a per-tenant-KMS
148
- // provider keys its wrap/unwrap on it. EnvMasterKeyProvider (app-wide
149
- // keyring) ignores it — the param exists so the contract doesn't have to
150
- // break when a tenant-scoped provider ships.
151
- export type KeyScope = {
152
- readonly tenantId?: TenantId;
153
- };
154
-
155
140
  // The contract a KEK backend must fulfil. The framework sees only this
156
141
  // interface; concrete implementations live in separate packages
157
142
  // (@cosmicdrift/kumiko-secrets-vault, @cosmicdrift/kumiko-secrets-aws-kms, ...). The default is
@@ -160,12 +145,12 @@ export interface MasterKeyProvider {
160
145
  // Wrap a fresh DEK with the current KEK. Returns the wrapped bytes + the
161
146
  // KEK version used — the version ends up in the Envelope so decryption
162
147
  // later knows which KEK to ask for.
163
- wrapDek(dek: Buffer, scope?: KeyScope): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
148
+ wrapDek(dek: Buffer): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
164
149
 
165
150
  // Unwrap a previously-wrapped DEK. During rotation the provider must
166
151
  // accept older kekVersion values (2-version window minimum), otherwise
167
152
  // old rows become unreadable.
168
- unwrapDek(encryptedDek: Buffer, kekVersion: number, scope?: KeyScope): Promise<Buffer>;
153
+ unwrapDek(encryptedDek: Buffer, kekVersion: number): Promise<Buffer>;
169
154
 
170
155
  // Which KEK version new wraps use. Rotation flips this to a new value
171
156
  // and older-version reads continue to work until rows are re-wrapped.
@@ -112,9 +112,6 @@ export function generateE2ESpec(
112
112
  // pro Field geschrieben, ohne CRUD-Zustand zu generieren wäre der
113
113
  // Spec wertlos. Branding/SMTP/etc. sind Author-spezifisch.
114
114
  if (screen.type === "configEdit") continue;
115
- // projectionList: query-getrieben, Author-spezifische Projection —
116
- // kein generischer CRUD-Spec ableitbar (wie actionForm/configEdit).
117
- if (screen.type === "projectionList") continue;
118
115
  const { scope: feature, name: short } = parseQn(screenQn);
119
116
  const urlPath = `/t/${tenant}/${feature}/${short}`;
120
117
  const title = `${feature}/${short}`;
@@ -25,8 +25,6 @@ export { createLateBoundHolder, type LateBoundHolder } from "./late-bound";
25
25
  export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper";
26
26
  export {
27
27
  createMutableMasterKeyProvider,
28
- createTestEnvelopeCipher,
29
- createTestMasterKeyProvider,
30
28
  type MutableMasterKeyProvider,
31
29
  } from "./mutable-master-key-provider";
32
30
  export {
@@ -6,14 +6,7 @@
6
6
  // tests that want to exercise pre- and post-rotation behaviour in a
7
7
  // single suite.
8
8
 
9
- import { randomBytes } from "node:crypto";
10
- import {
11
- createEnvelopeCipher,
12
- createEnvMasterKeyProvider,
13
- type EnvelopeCipher,
14
- type EnvelopeCipherOptions,
15
- type MasterKeyProvider,
16
- } from "../secrets";
9
+ import type { MasterKeyProvider } from "../secrets";
17
10
 
18
11
  export type MutableMasterKeyProvider = MasterKeyProvider & {
19
12
  // Replace the backing provider. All future wrapDek/unwrapDek/currentVersion
@@ -27,8 +20,8 @@ export function createMutableMasterKeyProvider(
27
20
  ): MutableMasterKeyProvider {
28
21
  let current = initial;
29
22
  return {
30
- wrapDek: (dek, scope) => current.wrapDek(dek, scope),
31
- unwrapDek: (e, v, scope) => current.unwrapDek(e, v, scope),
23
+ wrapDek: (dek) => current.wrapDek(dek),
24
+ unwrapDek: (e, v) => current.unwrapDek(e, v),
32
25
  currentVersion: () => current.currentVersion(),
33
26
  isAvailable: () => current.isAvailable(),
34
27
  replace: (next) => {
@@ -36,22 +29,3 @@ export function createMutableMasterKeyProvider(
36
29
  },
37
30
  };
38
31
  }
39
-
40
- // Single-version env provider for tests — the shape every integration test
41
- // needs to exercise encrypted config keys / entity fields without caring
42
- // about keyring mechanics. Pass a fixed key to share it across stacks.
43
- export function createTestMasterKeyProvider(keyBase64?: string): MasterKeyProvider {
44
- return createEnvMasterKeyProvider({
45
- env: {
46
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
47
- KUMIKO_SECRETS_MASTER_KEY_V1: keyBase64 ?? randomBytes(32).toString("base64"),
48
- },
49
- });
50
- }
51
-
52
- export function createTestEnvelopeCipher(
53
- keyBase64?: string,
54
- opts?: EnvelopeCipherOptions,
55
- ): EnvelopeCipher {
56
- return createEnvelopeCipher(createTestMasterKeyProvider(keyBase64), opts ?? {});
57
- }
@@ -1,7 +1,9 @@
1
- // Pacific/Apia sits far east of the dateline at UTC+13/+14 — a morning wall
2
- // clock there falls on the PREVIOUS UTC calendar day. Phrased as a date-
3
- // ordering property (not a fixed offset) so the test stays green regardless
4
- // of the tzdata DST view (UTC+13 and +14 both push 10:00 to the prior day).
1
+ // Pacific/Apia (Samoa) liegt weit östlich der Datumsgrenze bei UTC+13/+14 —
2
+ // eine Vormittags-Wall-Clock dort fällt in UTC auf den VORHERIGEN Kalendertag.
3
+ // Das Plan-Doc (timezones.md) nennt Apia explizit als Datumsgrenzen-Edge-Case
4
+ // der TZ-Matrix. Als Datums-Ordnungs-Eigenschaft formuliert, damit der Test
5
+ // unabhängig von der DST-Sicht der tzdata grün bleibt (UTC+13 wie +14 schieben
6
+ // 10:00 auf den Vortag).
5
7
 
6
8
  import { beforeAll, describe, expect, test } from "bun:test";
7
9
  import { ensureTemporalPolyfill } from "../polyfill";
@@ -11,8 +13,8 @@ beforeAll(async () => {
11
13
  await ensureTemporalPolyfill();
12
14
  });
13
15
 
14
- describe("ctx.tz — Pacific/Apia dateline", () => {
15
- test("a morning wall clock in Apia maps to the previous UTC calendar day", () => {
16
+ describe("ctx.tz — Pacific/Apia Datumsgrenze", () => {
17
+ test("Vormittags-Wall-Clock in Apia mappt auf den vorherigen UTC-Kalendertag", () => {
16
18
  const tz = createTzContext();
17
19
  const zdt = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
18
20
  expect(zdt.timeZoneId).toBe("Pacific/Apia");
@@ -20,11 +22,11 @@ describe("ctx.tz — Pacific/Apia dateline", () => {
20
22
  const utcDate = zdt.toInstant().toZonedDateTimeISO("UTC").toPlainDate().toString();
21
23
  expect(utcDate).toBe("2026-01-14");
22
24
 
23
- // Offset is a large positive value (UTC+13 or +14).
25
+ // Offset ist ein großer positiver Wert (UTC+13 oder +14).
24
26
  expect(zdt.offsetNanoseconds).toBeGreaterThan(12 * 3600 * 1e9);
25
27
  });
26
28
 
27
- test("round-trip preserves wall clock + instant across the dateline", () => {
29
+ test("Round-Trip bewahrt Wall-Clock + Instant über die Datumsgrenze", () => {
28
30
  const tz = createTzContext();
29
31
  const original = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
30
32
  const restored = tz.fromLocatedJson(tz.toLocatedJson(original));
@@ -57,7 +57,6 @@ export type {
57
57
  FieldRenderer,
58
58
  ListColumnSpec,
59
59
  PlatformComponent,
60
- ProjectionListScreenDefinition,
61
60
  RowAction,
62
61
  RowActionNavigate,
63
62
  RowActionWriteHandler,
@@ -1,36 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { patAllows, qnMatches } from "../pat-scope";
3
-
4
- describe("qnMatches", () => {
5
- it("exact match", () => {
6
- expect(qnMatches("credit:query:schedule", "credit:query:schedule")).toBe(true);
7
- expect(qnMatches("credit:query:schedule", "credit:query:credit:list")).toBe(false);
8
- });
9
-
10
- it("wildcard suffix matches on prefix", () => {
11
- expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:create")).toBe(true);
12
- expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:delete")).toBe(true);
13
- expect(qnMatches("credit:write:bauspar:*", "credit:write:create")).toBe(false);
14
- });
15
-
16
- it("bare wildcard matches everything", () => {
17
- expect(qnMatches("*", "anything:at:all")).toBe(true);
18
- });
19
- });
20
-
21
- describe("patAllows", () => {
22
- const allowed = ["credit:write:create", "credit:write:bauspar:*"];
23
-
24
- it("permits an exact or wildcard-covered type", () => {
25
- expect(patAllows(allowed, "credit:write:create")).toBe(true);
26
- expect(patAllows(allowed, "credit:write:bauspar:update")).toBe(true);
27
- });
28
-
29
- it("denies a type no scope covers", () => {
30
- expect(patAllows(allowed, "credit:write:credit:delete")).toBe(false);
31
- });
32
-
33
- it("fail-closed on an empty allow-list", () => {
34
- expect(patAllows([], "credit:write:create")).toBe(false);
35
- });
36
- });
@@ -1,14 +0,0 @@
1
- // QN scope-matching for Personal Access Tokens. A token's granted scopes
2
- // expand (in the PAT feature's resolver) to QN globs like "credit:write:*" or
3
- // "credit:query:credit:list". A glob ending in "*" matches any dispatch type
4
- // sharing the prefix; otherwise it is an exact match. Fail-closed: an empty
5
- // allow-list matches nothing, so a PAT with no scopes can call nothing.
6
-
7
- export function qnMatches(pattern: string, type: string): boolean {
8
- if (pattern.endsWith("*")) return type.startsWith(pattern.slice(0, -1));
9
- return pattern === type;
10
- }
11
-
12
- export function patAllows(allowedQns: readonly string[], type: string): boolean {
13
- return allowedQns.some((pattern) => qnMatches(pattern, type));
14
- }
@@ -1,123 +0,0 @@
1
- // r.extendEntityProjection — registrar/registry contracts (#759). The
2
- // runtime rebuild-replay behaviour is proven in
3
- // custom-fields/__tests__/custom-fields.integration.test.ts.
4
-
5
- import { describe, expect, test } from "bun:test";
6
- import { z } from "zod";
7
- import { defineFeature } from "../define-feature";
8
- import { createEntity, createTextField } from "../factories";
9
- import { createRegistry } from "../registry";
10
-
11
- function exampleEntity(name = "unit") {
12
- return createEntity({
13
- table: name,
14
- fields: { name: createTextField() },
15
- });
16
- }
17
-
18
- const noopApply = async (): Promise<void> => {};
19
-
20
- describe("r.extendEntityProjection — registration", () => {
21
- test("merges apply keys + extraSources into the implicit projection", () => {
22
- const feature = defineFeature("test", (r) => {
23
- r.entity("unit", exampleEntity());
24
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
25
- r.extendEntityProjection("unit", {
26
- sources: ["field-definition"],
27
- apply: { [setEvent.name]: noopApply },
28
- });
29
- });
30
- const registry = createRegistry([feature]);
31
- const projection = registry.getAllProjections().get("test:projection:unit-entity");
32
- expect(projection).toBeDefined();
33
- expect(projection?.apply["test:event:custom-field-set"]).toBe(noopApply);
34
- expect(projection?.apply["unit.created"]).toBeDefined();
35
- expect(projection?.extraSources).toEqual(["field-definition"]);
36
- expect(projection?.source).toBe("unit");
37
- });
38
-
39
- test("extension source equal to the entity name is not duplicated into extraSources", () => {
40
- const feature = defineFeature("test", (r) => {
41
- r.entity("unit", exampleEntity());
42
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
43
- r.extendEntityProjection("unit", {
44
- sources: ["unit"],
45
- apply: { [setEvent.name]: noopApply },
46
- });
47
- });
48
- const registry = createRegistry([feature]);
49
- const projection = registry.getAllProjections().get("test:projection:unit-entity");
50
- expect(projection?.extraSources).toBeUndefined();
51
- });
52
-
53
- test("auto-verb of a registered extraSources entity is a valid apply-key", () => {
54
- const feature = defineFeature("test", (r) => {
55
- r.entity("unit", exampleEntity());
56
- r.entity("field-definition", exampleEntity("field_definitions"));
57
- r.extendEntityProjection("unit", {
58
- sources: ["field-definition"],
59
- apply: { "field-definition.deleted": noopApply },
60
- });
61
- });
62
- const registry = createRegistry([feature]);
63
- const projection = registry.getAllProjections().get("test:projection:unit-entity");
64
- expect(projection?.apply["field-definition.deleted"]).toBe(noopApply);
65
- });
66
-
67
- test("registration order is free: extension before r.entity in the same feature", () => {
68
- const feature = defineFeature("test", (r) => {
69
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
70
- r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
71
- r.entity("unit", exampleEntity());
72
- });
73
- const registry = createRegistry([feature]);
74
- const projection = registry.getAllProjections().get("test:projection:unit-entity");
75
- expect(projection?.apply["test:event:custom-field-set"]).toBe(noopApply);
76
- });
77
-
78
- test("empty apply throws at registration time", () => {
79
- expect(() =>
80
- defineFeature("test", (r) => {
81
- r.entity("unit", exampleEntity());
82
- r.extendEntityProjection("unit", { apply: {} });
83
- }),
84
- ).toThrow(/no apply handlers/);
85
- });
86
- });
87
-
88
- describe("r.extendEntityProjection — registry-build validation", () => {
89
- test("unknown entity fails at registry build", () => {
90
- const feature = defineFeature("test", (r) => {
91
- r.entity("unit", exampleEntity());
92
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
93
- r.extendEntityProjection("typo-entity", { apply: { [setEvent.name]: noopApply } });
94
- });
95
- expect(() => createRegistry([feature])).toThrow(/no r\.entity/);
96
- });
97
-
98
- test("apply-key collision with a lifecycle apply fails at registry build", () => {
99
- const feature = defineFeature("test", (r) => {
100
- r.entity("unit", exampleEntity());
101
- r.extendEntityProjection("unit", { apply: { "unit.created": noopApply } });
102
- });
103
- expect(() => createRegistry([feature])).toThrow(/collides/);
104
- });
105
-
106
- test("apply-key collision between two extensions fails at registry build", () => {
107
- const feature = defineFeature("test", (r) => {
108
- r.entity("unit", exampleEntity());
109
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
110
- r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
111
- r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
112
- });
113
- expect(() => createRegistry([feature])).toThrow(/collides/);
114
- });
115
-
116
- test("unknown apply-key (neither auto-verb nor domain event) fails at registry build", () => {
117
- const feature = defineFeature("test", (r) => {
118
- r.entity("unit", exampleEntity());
119
- r.extendEntityProjection("unit", { apply: { "customField.set": noopApply } });
120
- });
121
- expect(() => createRegistry([feature])).toThrow(/no such event/);
122
- });
123
- });
@@ -1,111 +0,0 @@
1
- // Dead-letter storage for apply-handler failures during projection rebuild.
2
- //
3
- // Background: rebuildProjection / rebuildMultiStreamProjection replay the
4
- // event log through apply handlers inside ONE transaction. A single event
5
- // whose (possibly years-old) payload makes its apply throw rolls the whole
6
- // replay back — the projection stays permanently un-rebuildable until the
7
- // event is repaired by hand. The upcaster dead-letter only quarantines
8
- // upcast-TRANSFORM failures, not apply failures (#760).
9
- //
10
- // Quarantine mode (errorPolicy.skipApplyErrors on RebuildDeps, or
11
- // MspErrorMode.rebuild.skipApplyErrors on the MSP definition) confines each
12
- // apply to a savepoint: a throwing apply is rolled back, captured into
13
- // `kumiko_rebuild_dead_letters`, and the replay continues. Replay-after-fix
14
- // is a separate ops step — same stance as the upcaster dead-letter.
15
-
16
- import type { DbConnection, DbRunner } from "../db/connection";
17
- import { bigint, index, jsonb, table as pgTable, text, timestamp, uuid } from "../db/dialect";
18
- import { tableExists } from "../db/schema-inspection";
19
- import { unsafePushTables } from "../stack";
20
- import type { StoredEvent } from "./event-store";
21
-
22
- export const rebuildDeadLetterTable = pgTable(
23
- "kumiko_rebuild_dead_letters",
24
- {
25
- // Surrogate PK — the same event can land here across multiple rebuild
26
- // attempts before the fix ships, without unique-violation noise.
27
- id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
28
- projectionName: text("projection_name").notNull(),
29
- // StoredEvent.id is surfaced as `string` (bigint serialised for JSON
30
- // safety) — stored as text to keep the round-trip identity.
31
- eventId: text("event_id").notNull(),
32
- tenantId: uuid("tenant_id").notNull(),
33
- aggregateId: text("aggregate_id").notNull(),
34
- aggregateType: text("aggregate_type").notNull(),
35
- eventType: text("event_type").notNull(),
36
- errorMessage: text("error_message").notNull(),
37
- payload: jsonb("payload").notNull(),
38
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
39
- },
40
- (t) => ({
41
- projectionIdx: index("rebuild_dead_letters_projection_idx").on(t.projectionName),
42
- createdAtIdx: index("rebuild_dead_letters_created_at_idx").on(t.createdAt),
43
- }),
44
- );
45
-
46
- // Idempotent table-create. The rebuild runners call this before the rebuild
47
- // tx whenever quarantine mode is active, so the opt-in works on databases
48
- // provisioned before this table existed.
49
- export async function createRebuildDeadLetterTable(db: DbConnection): Promise<void> {
50
- // skip: table already exists — bootstrap called from multiple paths
51
- if (await tableExists(db, "public.kumiko_rebuild_dead_letters")) return;
52
- await unsafePushTables(db, { kumikoRebuildDeadLetters: rebuildDeadLetterTable });
53
- }
54
-
55
- export type SkippedApply = {
56
- readonly event: StoredEvent;
57
- readonly error: unknown;
58
- };
59
-
60
- // Bulk-write, called ONCE at the end of a quarantining rebuild (inside its
61
- // tx). Unqualified insert — during the rebuild the search_path points at the
62
- // shadow schema, and this table exists only in public, so it falls through.
63
- export async function recordRebuildDeadLetters(
64
- db: DbRunner,
65
- projectionName: string,
66
- skipped: readonly SkippedApply[],
67
- ): Promise<void> {
68
- const { insertMany } = await import("../bun-db/query");
69
- await insertMany(
70
- db,
71
- rebuildDeadLetterTable,
72
- skipped.map(({ event, error }) => ({
73
- projectionName,
74
- eventId: event.id,
75
- tenantId: event.tenantId,
76
- aggregateId: event.aggregateId,
77
- aggregateType: event.aggregateType,
78
- eventType: event.type,
79
- errorMessage: error instanceof Error ? error.message : String(error),
80
- payload: event.payload,
81
- })),
82
- );
83
- }
84
-
85
- export type RebuildDeadLetterRow = {
86
- readonly id: bigint;
87
- readonly projectionName: string;
88
- readonly eventId: string;
89
- readonly tenantId: string;
90
- readonly aggregateId: string;
91
- readonly aggregateType: string;
92
- readonly eventType: string;
93
- readonly errorMessage: string;
94
- readonly payload: Record<string, unknown>;
95
- readonly createdAt: Date;
96
- };
97
-
98
- // Ops-side triage query, optionally scoped to one projection.
99
- export async function listRebuildDeadLetters(
100
- db: DbConnection,
101
- options: { projectionName?: string; limit?: number } = {},
102
- ): Promise<readonly RebuildDeadLetterRow[]> {
103
- const { selectMany } = await import("../bun-db/query");
104
- const limit = options.limit ?? 100;
105
- const where =
106
- options.projectionName !== undefined ? { projectionName: options.projectionName } : undefined;
107
- return selectMany<RebuildDeadLetterRow>(db, rebuildDeadLetterTable, where, {
108
- orderBy: { col: "createdAt", direction: "desc" },
109
- limit,
110
- });
111
- }
@@ -1,70 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import type { Registry } from "../../engine/types";
3
- import { makeFileProviderResolver } from "../provider-resolver";
4
- import type { FileStorageProvider } from "../types";
5
-
6
- const fakeProvider = { name: "fake" } as unknown as FileStorageProvider;
7
-
8
- function fakeRegistry(): Registry {
9
- return {
10
- getExtensionUsages: () => [
11
- {
12
- entityName: "fake",
13
- options: { build: async () => fakeProvider },
14
- },
15
- ],
16
- } as unknown as Registry;
17
- }
18
-
19
- // 698/2: resolveProvider must not re-read config + secrets on every call —
20
- // the provider is effectively static per tenant for the process lifetime.
21
- describe("makeFileProviderResolver — per-tenant cache", () => {
22
- test("a second resolve for the same tenant reuses the cached build, doesn't call configAccessorFactory again", async () => {
23
- const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
24
- const resolver = makeFileProviderResolver({
25
- registry: fakeRegistry(),
26
- _configAccessorFactory: configAccessorFactory,
27
- db: {} as never,
28
- });
29
-
30
- const first = await resolver("tenant-a" as never);
31
- const second = await resolver("tenant-a" as never);
32
-
33
- expect(first).toBe(fakeProvider);
34
- expect(second).toBe(fakeProvider);
35
- expect(configAccessorFactory).toHaveBeenCalledTimes(1);
36
- });
37
-
38
- test("different tenants get independent cache entries", async () => {
39
- const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
40
- const resolver = makeFileProviderResolver({
41
- registry: fakeRegistry(),
42
- _configAccessorFactory: configAccessorFactory,
43
- db: {} as never,
44
- });
45
-
46
- await resolver("tenant-a" as never);
47
- await resolver("tenant-b" as never);
48
-
49
- expect(configAccessorFactory).toHaveBeenCalledTimes(2);
50
- });
51
-
52
- test("a rejected build is evicted — the next call retries instead of staying poisoned", async () => {
53
- let calls = 0;
54
- const resolver = makeFileProviderResolver({
55
- registry: {
56
- getExtensionUsages: () => {
57
- calls++;
58
- if (calls === 1) throw new Error("transient failure");
59
- return [{ entityName: "fake", options: { build: async () => fakeProvider } }];
60
- },
61
- } as unknown as Registry,
62
- _configAccessorFactory: () => async (_key: unknown) => "fake",
63
- db: {} as never,
64
- });
65
-
66
- await expect(resolver("tenant-a" as never)).rejects.toThrow("transient failure");
67
- const second = await resolver("tenant-a" as never);
68
- expect(second).toBe(fakeProvider);
69
- });
70
- });