@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Glob } from "bun";
3
+
4
+ // getUnscopedAggregateStreamMaxVersion / getUnscopedAggregateStreamTenant have
5
+ // no tenant filter — a caller can use them to probe whether a foreign tenant's
6
+ // aggregate exists (see event-store.ts SECURITY doc). Restricted to known
7
+ // seed/system-internal callers; extend only for genuine new ones.
8
+ const RESTRICTED_SYMBOLS = [
9
+ "getUnscopedAggregateStreamMaxVersion",
10
+ "getUnscopedAggregateStreamTenant",
11
+ ];
12
+
13
+ const ALLOWED_FILES = new Set([
14
+ "packages/framework/src/event-store/event-store.ts",
15
+ "packages/framework/src/event-store/index.ts",
16
+ "packages/bundled-features/src/tenant/seeding.ts",
17
+ "packages/bundled-features/src/tier-engine/feature.ts",
18
+ "packages/bundled-features/src/auth-email-password/__tests__/email-verification.integration.test.ts",
19
+ "packages/bundled-features/src/auth-email-password/__tests__/password-reset.integration.test.ts",
20
+ "packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
21
+ ]);
22
+
23
+ const REPO_ROOT = `${import.meta.dir}/../../../../..`;
24
+
25
+ describe("unscoped stream primitives — caller allowlist", () => {
26
+ test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
27
+ const glob = new Glob("packages/{framework,bundled-features}/src/**/*.ts");
28
+ const matches = new Set<string>();
29
+ for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
30
+ const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
31
+ if (RESTRICTED_SYMBOLS.some((symbol) => content.includes(symbol))) {
32
+ matches.add(relPath);
33
+ }
34
+ }
35
+
36
+ // Positive control — proves the scan actually ran and found the known
37
+ // caller, not just that it (silently) found nothing.
38
+ expect(matches.has("packages/bundled-features/src/tenant/seeding.ts")).toBe(true);
39
+
40
+ const offenders = [...matches].filter((relPath) => !ALLOWED_FILES.has(relPath));
41
+ expect(offenders).toEqual([]);
42
+ });
43
+ });
@@ -1,35 +1,2 @@
1
- // Failure modes of the event-store's append() path. Surfaced as typed
2
- // errors so the executor layer can map them to the framework's
3
- // WriteResult error contract (version_conflict).
4
-
5
- export class VersionConflictError extends Error {
6
- public readonly aggregateId: string;
7
- public readonly expectedVersion: number;
8
- constructor(aggregateId: string, expectedVersion: number) {
9
- super(
10
- `Version conflict on aggregate ${aggregateId}: expected predecessor version ${expectedVersion}`,
11
- );
12
- this.name = "VersionConflictError";
13
- this.aggregateId = aggregateId;
14
- this.expectedVersion = expectedVersion;
15
- }
16
- }
17
-
18
- // Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
19
- // are read-only — restoreStream() makes them writable again. The archive
20
- // state is not carried on the events themselves; it lives on the sparse
21
- // kumiko_archived_streams table. Handlers that need to branch on archive
22
- // state should call ctx.isStreamArchived(id) first.
23
- export class ArchivedStreamError extends Error {
24
- public readonly tenantId: string;
25
- public readonly aggregateId: string;
26
- constructor(tenantId: string, aggregateId: string) {
27
- super(
28
- `Aggregate ${aggregateId} on tenant ${tenantId} is archived — appendEvent is blocked. ` +
29
- `Call restoreStream() to re-open the stream before writing.`,
30
- );
31
- this.name = "ArchivedStreamError";
32
- this.tenantId = tenantId;
33
- this.aggregateId = aggregateId;
34
- }
35
- }
1
+ // Legacy path re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/event-store-errors";
@@ -1,3 +1,4 @@
1
+ import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
1
2
  import { encryptEventPayloadPii } from "../crypto/event-pii";
2
3
  import type { DbRunner } from "../db";
3
4
  import { isUniqueViolation } from "../db/pg-error";
@@ -16,28 +17,7 @@ import { VersionConflictError } from "./errors";
16
17
  import { eventsTable } from "./events-schema";
17
18
  import { toStoredEvent } from "./row-to-stored-event";
18
19
 
19
- export type EventMetadata = {
20
- readonly userId: string;
21
- readonly requestId?: string;
22
- // End-to-end business-operation id. Root HTTP requests get it from the
23
- // x-correlation-id header (default: requestId). MSP-applies inherit it
24
- // from the triggering event. Lets you trace "which user click caused
25
- // this email 3 streams later?".
26
- readonly correlationId?: string;
27
- // Stored event id that triggered this write. Null for root commands;
28
- // set to event.id when an MSP-apply runs ctx.appendEvent. Together with
29
- // correlationId forms a causation DAG across aggregate streams.
30
- readonly causationId?: string;
31
- // Marten-conform free key/value space for app-specific metadata that
32
- // doesn't deserve its own EventMetadata field. Examples: A/B-test bucket,
33
- // feature-flag snapshot, geo-region, client SDK version. Persisted into
34
- // events.metadata jsonb (no schema change — it's already a free-form
35
- // jsonb column), survives upcasters untouched, available on every
36
- // StoredEvent.metadata.headers. Framework does not interpret values; the
37
- // app reads them when filtering/auditing. Keep values JSON-primitive
38
- // (string|number|boolean) so JSON serialization stays bulletproof.
39
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
40
- };
20
+ export type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
41
21
 
42
22
  export type EventToAppend = {
43
23
  readonly aggregateId: string;
@@ -51,25 +31,6 @@ export type EventToAppend = {
51
31
  readonly metadata: EventMetadata;
52
32
  };
53
33
 
54
- // Generic über payload-shape. Default = Record<string, unknown> macht
55
- // alle existierenden Konsumenten backwards-compatible. Konkrete Apply-
56
- // Handler / Tests können `StoredEvent<MyEventPayload>` annotieren um
57
- // payload typed zu lesen. Type-Propagation kommt durch r.defineEvent +
58
- // SingleStreamApplyFn<T> in apply-Maps.
59
- export type StoredEvent<TPayload = Record<string, unknown>> = {
60
- readonly id: string;
61
- readonly aggregateId: string;
62
- readonly aggregateType: string;
63
- readonly tenantId: TenantId;
64
- readonly version: number;
65
- readonly type: string;
66
- readonly eventVersion: number;
67
- readonly payload: TPayload;
68
- readonly metadata: EventMetadata;
69
- readonly createdAt: Temporal.Instant;
70
- readonly createdBy: string;
71
- };
72
-
73
34
  type SelectedEvent = {
74
35
  readonly id: bigint;
75
36
  readonly aggregateId: string;
@@ -281,9 +242,13 @@ export async function getStreamVersion(
281
242
  return selectStreamMaxVersion(db, aggregateId, tenantId);
282
243
  }
283
244
 
284
- /** MAX(version) for one aggregate — no tenant filter. Used by seed idempotency. */
245
+ /** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
246
+ * a caller can probe whether an aggregateId has any events regardless of tenant
247
+ * membership. Only call from seed/system-internal paths (idempotency checks
248
+ * against a known aggregateId) — never from a handler reachable with
249
+ * caller-controlled input. */
285
250
  // @wrapper-known semantic-alias
286
- export async function getAggregateStreamMaxVersion(
251
+ export async function getUnscopedAggregateStreamMaxVersion(
287
252
  db: DbRunner,
288
253
  aggregateId: string,
289
254
  ): Promise<number> {
@@ -291,10 +256,11 @@ export async function getAggregateStreamMaxVersion(
291
256
  }
292
257
 
293
258
  /** Stream tenant of an aggregate (the tenant_id its events live under), with no
294
- * membership/tenant filter. Recovers the write target for a systemScope
295
- * aggregate whose stream tenant isn't one of the subject's memberships.
296
- * Returns null for unknown streams. */
297
- export async function getAggregateStreamTenant(
259
+ * membership/tenant filter. SECURITY: existence-oracle, same caveat as
260
+ * getUnscopedAggregateStreamMaxVersion seed/system-internal use only. Recovers
261
+ * the write target for a systemScope aggregate whose stream tenant isn't one of
262
+ * the subject's memberships. Returns null for unknown streams. */
263
+ export async function getUnscopedAggregateStreamTenant(
298
264
  db: DbRunner,
299
265
  aggregateId: string,
300
266
  aggregateType: string,
@@ -333,15 +299,19 @@ export async function loadEventsAfterVersion(
333
299
 
334
300
  // Load every event for an aggregate_type across all tenants. Ordered by
335
301
  // (created_at, id) — chronological replay order for projection rebuilds.
336
- //
337
- // CAUTION — buffers ALL matching events in memory. Safe for smaller
338
- // aggregate-types (≤ 100k events), a memory cliff for large stores.
339
- // For >100k events use `streamAllEventsByType` (yields batchwise).
340
302
  // Mostly called from tests today — production rebuild goes through
341
303
  // projection-rebuild's own streaming path.
304
+ //
305
+ // Fails loud past LOAD_ALL_EVENTS_ROW_LIMIT rather than silently buffering
306
+ // an unbounded result set — that's the memory cliff this guard exists to
307
+ // prevent.
308
+ export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
309
+
310
+ /** @deprecated buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) instead. */
342
311
  export async function loadAllEventsByType(
343
312
  db: DbRunner,
344
313
  aggregateType: string,
314
+ rowLimit: number = LOAD_ALL_EVENTS_ROW_LIMIT,
345
315
  ): Promise<readonly StoredEvent[]> {
346
316
  const rows = await selectMany<SelectedEvent>(
347
317
  db,
@@ -352,8 +322,15 @@ export async function loadAllEventsByType(
352
322
  { col: "createdAt", direction: "asc" },
353
323
  { col: "id", direction: "asc" },
354
324
  ],
325
+ limit: rowLimit + 1,
355
326
  },
356
327
  );
328
+ if (rows.length > rowLimit) {
329
+ throw new Error(
330
+ `loadAllEventsByType("${aggregateType}") exceeds ${rowLimit} rows — ` +
331
+ "use streamAllEventsByType instead of buffering the full result set in memory.",
332
+ );
333
+ }
357
334
  return rows.map(toStoredEvent);
358
335
  }
359
336
 
@@ -15,6 +15,7 @@ import {
15
15
  import { unsafePushTables } from "../stack";
16
16
  import { createArchivedStreamsTable } from "./archive";
17
17
  import { createSnapshotsTable } from "./snapshot";
18
+ import type { EventMetadata } from "./types";
18
19
 
19
20
  // Event-store schema as a Drizzle table. The typed select/insert path handles
20
21
  // most operations; append() for subsequent versions uses raw SQL because
@@ -24,16 +25,6 @@ import { createSnapshotsTable } from "./snapshot";
24
25
  // (Redis-backed check + cached-response replay). The event-store itself
25
26
  // imposes no idempotency index — a single HTTP request may write N events
26
27
  // freely, metadata.requestId is purely a trace marker.
27
- export type EventMetadata = {
28
- readonly userId: string;
29
- readonly requestId?: string;
30
- readonly correlationId?: string;
31
- readonly causationId?: string;
32
- // App-specific free key/value (Marten "headers"). Mirror of the canonical
33
- // type in event-store.ts — kept duplicate because events-schema must stay
34
- // import-cycle-free vs the event-store module.
35
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
36
- };
37
28
 
38
29
  export const eventsTable = pgTable(
39
30
  "kumiko_events",
@@ -18,10 +18,11 @@ export {
18
18
  EVENTS_PUBSUB_CHANNEL,
19
19
  type EventMetadata,
20
20
  type EventToAppend,
21
- getAggregateStreamMaxVersion,
22
- getAggregateStreamTenant,
23
21
  getEventsHighWaterMark,
24
22
  getStreamVersion,
23
+ getUnscopedAggregateStreamMaxVersion,
24
+ getUnscopedAggregateStreamTenant,
25
+ LOAD_ALL_EVENTS_ROW_LIMIT,
25
26
  loadAggregate,
26
27
  loadAggregateAsOf,
27
28
  loadAllEventsByType,
@@ -1,5 +1,10 @@
1
1
  // sql now comes from native dialect
2
2
 
3
+ import type {
4
+ LoadAggregateWithSnapshotOptions,
5
+ LoadAggregateWithSnapshotResult,
6
+ SnapshotReducer,
7
+ } from "@cosmicdrift/kumiko-types/snapshot-types";
3
8
  import type { DbConnection, DbRunner } from "../db/connection";
4
9
  import {
5
10
  index,
@@ -18,7 +23,7 @@ import { tableExists } from "../db/schema-inspection";
18
23
  import type { TenantId } from "../engine/types";
19
24
  import { unsafePushTables } from "../stack";
20
25
  import { isStreamArchived } from "./archive";
21
- import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
26
+ import { loadEventsAfterVersion } from "./event-store";
22
27
 
23
28
  // Marten-aligned snapshot store. A snapshot is a point-in-time materialised
24
29
  // state of an aggregate at a specific version, cached so rehydrating the
@@ -157,40 +162,11 @@ export async function loadLatestSnapshot<
157
162
  };
158
163
  }
159
164
 
160
- // Reducer used to fold events onto a state. Kept narrow and pure — the
161
- // caller supplies the shape and update rules. Mirrors the reducer shape
162
- // feature authors already write for r.projection.apply.
163
- export type SnapshotReducer<TState extends Record<string, unknown>> = (
164
- state: TState,
165
- event: StoredEvent,
166
- ) => TState;
167
-
168
- export type LoadAggregateWithSnapshotResult<TState extends Record<string, unknown>> = {
169
- readonly state: TState;
170
- readonly version: number;
171
- readonly snapshotHit: boolean;
172
- };
173
-
174
- export type LoadAggregateWithSnapshotOptions = {
175
- // Opt-in: include archived streams in the rehydrate. Default false — same
176
- // semantics as loadAggregate / loadAggregateAsOf. Archive check is a
177
- // single indexed lookup, so the cost stays negligible on the hot path.
178
- readonly includeArchived?: boolean;
179
- // Optional upcaster step: every delta event goes through this transform
180
- // BEFORE the reducer sees it. The dispatcher wires this up with
181
- // r.eventMigration so feature code always sees current-version payloads.
182
- // Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
183
- readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
184
- // Auto-snapshot policy: when the fold applied at least this many delta
185
- // events, persist a fresh snapshot at the folded version (best-effort —
186
- // a failed save never fails the load). Omit to keep snapshotting manual.
187
- readonly snapshotEvery?: number;
188
- // Reducer-shape generation stamped onto saved snapshots (default 1). A
189
- // stored snapshot with a different generation is ignored — full replay
190
- // through the upcaster chain — and restamped on the next auto-save. Bump
191
- // whenever the reducer's state shape changes.
192
- readonly snapshotVersion?: number;
193
- };
165
+ export type {
166
+ LoadAggregateWithSnapshotOptions,
167
+ LoadAggregateWithSnapshotResult,
168
+ SnapshotReducer,
169
+ } from "@cosmicdrift/kumiko-types/snapshot-types";
194
170
 
195
171
  // Snapshot-aware rehydrate. Loads the latest snapshot (if any), applies
196
172
  // events strictly newer than snapshot.version, and returns the fold.
@@ -0,0 +1,2 @@
1
+ // Legacy path — re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/event-store-types";
@@ -0,0 +1,4 @@
1
+ import { describeFileProviderContract } from "../../testing/file-provider-contract";
2
+ import { createInMemoryFileProvider } from "../in-memory-provider";
3
+
4
+ describeFileProviderContract("InMemoryFileProvider", () => createInMemoryFileProvider());
@@ -10,27 +10,10 @@
10
10
  // suffix before the file extension — `foo/bar.jpg` + `"medium"` →
11
11
  // `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
12
12
 
13
+ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
13
14
  import type { FileStorageProvider } from "./types";
14
15
 
15
- export type FileHandle = {
16
- readonly key: string;
17
- read(): Promise<Uint8Array>;
18
- write(data: Uint8Array, mimeType?: string): Promise<void>;
19
- delete(): Promise<void>;
20
- exists(): Promise<boolean>;
21
- // Produce a handle for a derived key (e.g. a thumbnail). Does not touch
22
- // storage; only computes the key. Writing to the derived handle is the
23
- // caller's job.
24
- derive(suffix: string): FileHandle;
25
- };
26
-
27
- // The `ctx.files` service — a factory that materialises a FileHandle for a
28
- // storage key. One per request/event, bound to a single tenant: the provider
29
- // is resolved per-tenant through file-foundation, so uploads, ctx.files and the
30
- // GDPR jobs all hit the same store by construction.
31
- export type FileContext = {
32
- ref(key: string): FileHandle;
33
- };
16
+ export type { FileContext, FileHandle };
34
17
 
35
18
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
36
19
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
@@ -12,11 +12,12 @@
12
12
  // file-foundation re-exports `createFileProviderForTenant` + the plugin types
13
13
  // (moved here from there) so existing imports keep working.
14
14
 
15
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
15
16
  import type { DbConnection } from "../db/connection";
16
17
  import type { TenantDb } from "../db/tenant-db";
17
18
  import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY } from "../engine/extension-names";
18
19
  import { SYSTEM_USER_ID } from "../engine/system-user";
19
- import type { ConfigAccessor, ConfigAccessorFactory, Registry, TenantId } from "../engine/types";
20
+ import type { ConfigAccessor, ConfigAccessorFactory, Registry } from "../engine/types";
20
21
  import type { SecretsContext } from "../secrets";
21
22
  import type { FileStorageProvider } from "./types";
22
23
 
@@ -120,10 +121,7 @@ export async function createFileProviderForTenant(
120
121
  return usage.options.build(ctx, tenantId);
121
122
  }
122
123
 
123
- // A bound, per-tenant provider resolver. One instance serves all tenants
124
- // (tenantId is the call argument) — the single spine shared by upload routes,
125
- // ctx.files and the GDPR jobs.
126
- export type FileProviderResolver = (tenantId: TenantId) => Promise<FileStorageProvider>;
124
+ export type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
127
125
 
128
126
  export type FileProviderResolverDeps = {
129
127
  readonly registry?: Registry;
@@ -6,60 +6,11 @@ export type FileMetadata = {
6
6
  readonly size: number;
7
7
  };
8
8
 
9
- // Options for `getSignedUrl`. `contentDisposition` lets the caller hint the
10
- // browser to download-with-name vs inline-display (maps to ResponseContent-
11
- // Disposition on S3). Keep the option-bag small and additive; provider impls
12
- // that don't support a given hint should ignore it rather than error.
13
- export type SignedUrlOptions = {
14
- readonly contentDisposition?: string;
15
- };
16
-
17
- // Options fuer `writeStream`. `mimeType` ist Content-Type-Hint analog zu
18
- // `write`. `contentLength` ist optional fuer Provider die einen Length-
19
- // Header brauchen (S3 multipart hat einen TransferManager, kann auch ohne
20
- // length); local-Provider ignoriert beides.
21
- export type WriteStreamOptions = {
22
- readonly mimeType?: string;
23
- readonly contentLength?: number;
24
- };
25
-
26
- // Primitive storage contract: key+bytes in, bytes out. Metadata (fileName,
27
- // mimeType, size) lives on the FileRef row — the provider only needs to
28
- // shuttle bytes. `mimeType` on write() is a hint for providers that need a
29
- // Content-Type header (S3/R2/…); local filesystems can ignore it.
30
- //
31
- // **Streaming (`writeStream` + `readStream`) ist PFLICHT** — beide
32
- // Methoden sind required, kein optional-feature. Begruendung:
33
- // - User-Data-Export (Atom 3c) braucht beide, sonst silent fail bei
34
- // erstem Job mit fileRefs in Production.
35
- // - Apps die nur kleine Files (Avatar-Uploads, Profile-Pics) handeln,
36
- // koennen trivial via `oneShot`-Pattern den Stream-Contract erfuellen
37
- // (single-chunk yield von write/read-Bytes). 5 Zeilen pro Provider.
38
- // - Optional-Type wuerde TypeScript-Lying erlauben: Type sagt "kann
39
- // fehlen", Worker throws zur Runtime → App-Authors sehen den Bug
40
- // erst in Production. Required + TS-enforced ist ehrlich.
41
- //
42
- // `getSignedUrl` BLEIBT optional: object-store backends (S3/R2/GCS)
43
- // implement it so clients can download directly from the provider after
44
- // the server has checked access — offloads bandwidth and enables browser-
45
- // native caching. Filesystem providers leave it undefined; the route then
46
- // returns 501 and the client falls back to streaming via GET /files/:id.
47
- // Callers must feature-detect via `typeof provider.getSignedUrl === "function"`.
48
- // Hier ist Optional korrekt weil die Fallback-Pfad existiert — kein
49
- // silent-fail, sondern 501 + alternativer download.
50
- export type FileStorageProvider = {
51
- write(key: string, data: Uint8Array, mimeType?: string): Promise<void>;
52
- writeStream(
53
- key: string,
54
- source: AsyncIterable<Uint8Array>,
55
- options?: WriteStreamOptions,
56
- ): Promise<void>;
57
- read(key: string): Promise<Uint8Array>;
58
- readStream(key: string): AsyncIterable<Uint8Array>;
59
- delete(key: string): Promise<void>;
60
- exists(key: string): Promise<boolean>;
61
- getSignedUrl?(key: string, expiresInSeconds: number, options?: SignedUrlOptions): Promise<string>;
62
- };
9
+ export type {
10
+ FileStorageProvider,
11
+ SignedUrlOptions,
12
+ WriteStreamOptions,
13
+ } from "@cosmicdrift/kumiko-types/file-storage-provider-types";
63
14
 
64
15
  export type FileValidationOptions = {
65
16
  readonly maxSize?: string | undefined;
@@ -1,3 +1,4 @@
1
+ import { isExtensionEditSection, normalizeListColumn } from "../engine/screen-helpers";
1
2
  import type {
2
3
  ActionFormScreenDefinition,
3
4
  ConfigEditScreenDefinition,
@@ -15,7 +16,6 @@ import type {
15
16
  ToolbarAction,
16
17
  WorkspaceDefinition,
17
18
  } from "../engine/types";
18
- import { isExtensionEditSection, normalizeListColumn } from "../engine/types/screen";
19
19
 
20
20
  /** Pseudo-entity for actionForm field labels (renderer action-form-shim). */
21
21
  export const ACTION_FORM_ENTITY = "__action-form__";
@@ -1,8 +1,9 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { requestContext } from "../../api/request-context";
3
4
  import { createRegistry, defineFeature } from "../../engine";
4
5
  import type { AppContext, Registry } from "../../engine/types";
5
- import { createTestRedis, type TestRedis } from "../../stack";
6
+ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
6
7
  import { sleep, waitFor } from "../../testing";
7
8
  import {
8
9
  createJobRunner,
@@ -103,6 +104,30 @@ const testFeature = defineFeature("test", (r) => {
103
104
  await sleep(500);
104
105
  },
105
106
  );
107
+ r.writeHandler(
108
+ "capped-event",
109
+ z.object({ n: z.number() }),
110
+ async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
111
+ { access: { openToAll: true } },
112
+ );
113
+ // Event-triggered twin — exercises handleEvent's maxPerTenant guard
114
+ // (dispatch and handleEvent share isOverPerTenantLimit).
115
+ r.job(
116
+ "perTenantLimitedEvent",
117
+ {
118
+ trigger: { on: "test:write:capped-event" },
119
+ concurrency: "parallel",
120
+ maxPerTenant: 2,
121
+ },
122
+ async (payload) => {
123
+ jobLog.push({
124
+ name: "test:job:per-tenant-limited-event",
125
+ payload,
126
+ timestamp: Date.now(),
127
+ });
128
+ await sleep(500);
129
+ },
130
+ );
106
131
 
107
132
  // Job that fails
108
133
  r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
@@ -130,6 +155,18 @@ const testFeature = defineFeature("test", (r) => {
130
155
  timestamp: Date.now(),
131
156
  });
132
157
  });
158
+ // Exercises createJobLogger (info/warn/error/debug/child) — otherwise those
159
+ // one-liners stay uncovered even though every job builds a logger.
160
+ r.job("logProbe", { trigger: { manual: true } }, async (payload, ctx) => {
161
+ expect(ctx.log).toBeDefined();
162
+ const log = ctx.log!;
163
+ log.info("log-probe-info", { n: payload["n"] });
164
+ log.warn("log-probe-warn");
165
+ log.error("log-probe-error", { ok: false });
166
+ log.debug("log-probe-debug");
167
+ log.child({ probe: true }).info("log-probe-child");
168
+ jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
169
+ });
133
170
  });
134
171
 
135
172
  beforeAll(async () => {
@@ -423,6 +460,26 @@ describe("concurrency: debounce", () => {
423
460
  });
424
461
  });
425
462
 
463
+ describe("concurrency: replace", () => {
464
+ test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
465
+ // No consumer → jobs stay waiting. The replace branch walks getWaiting()
466
+ // and removes same-name peers before add — so three dispatches leave one.
467
+ await withRunner(
468
+ async (runner) => {
469
+ const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
470
+ const id2 = await runner.dispatch("test:job:replace-job", { n: 2 });
471
+ const id3 = await runner.dispatch("test:job:replace-job", { n: 3 });
472
+ expect(id1).toBeDefined();
473
+ expect(id2).toBeDefined();
474
+ expect(id3).toBeDefined();
475
+ expect(id1).not.toBe("skipped");
476
+ expect(id3).not.toBe(id1);
477
+ },
478
+ { consumerLane: undefined },
479
+ );
480
+ });
481
+ });
482
+
426
483
  describe("concurrency: maxPerTenant", () => {
427
484
  test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
428
485
  clearLog();
@@ -486,6 +543,18 @@ describe("concurrency: maxPerTenant", () => {
486
543
 
487
544
  // --- Correlation propagation ---
488
545
 
546
+ describe("job logger", () => {
547
+ test("handler can call info/warn/error/debug/child without throw", async () => {
548
+ clearLog();
549
+ await withRunner(async (runner) => {
550
+ await runner.dispatch("test:job:log-probe", { n: 1 });
551
+ await waitFor(() => {
552
+ expect(jobLog.some((e) => e.name === "test:job:log-probe")).toBe(true);
553
+ });
554
+ });
555
+ });
556
+ });
557
+
489
558
  describe("correlation propagation", () => {
490
559
  test("dispatch inside requestContext.run passes correlationId into the job", async () => {
491
560
  clearLog();
@@ -599,6 +668,38 @@ describe("error handling", () => {
599
668
  });
600
669
  });
601
670
  });
671
+
672
+ test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
673
+ clearLog();
674
+ const tenants = [101, 102];
675
+ await withRunner(
676
+ async (runner) => {
677
+ await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
678
+ await waitFor(() => {
679
+ const runs = jobLog.filter((e) => e.name === "test:job:per-tenant-fanout");
680
+ expect(runs.length).toBe(2);
681
+ expect(runs.every((e) => e.payload["n"] === 7)).toBe(true);
682
+ });
683
+ },
684
+ { getActiveTenantIds: async () => tenants },
685
+ );
686
+ });
687
+ });
688
+
689
+ describe("handleEvent maxPerTenant", () => {
690
+ test("skips enqueue when tenant is already at the cap", async () => {
691
+ clearLog();
692
+ await withRunner(async (runner) => {
693
+ const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
694
+ await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
695
+ await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
696
+ // Third must be skipped by the maxPerTenant guard.
697
+ await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
698
+ await sleep(200);
699
+ const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
700
+ expect(started.length).toBeLessThanOrEqual(2);
701
+ });
702
+ });
602
703
  });
603
704
 
604
705
  // --- Registry ---
@@ -1,7 +1 @@
1
- export type Logger = {
2
- info(msg: string, data?: Record<string, unknown>): void;
3
- warn(msg: string, data?: Record<string, unknown>): void;
4
- error(msg: string, data?: Record<string, unknown>): void;
5
- debug(msg: string, data?: Record<string, unknown>): void;
6
- child(context: Record<string, unknown>): Logger;
7
- };
1
+ export * from "@cosmicdrift/kumiko-types/logger";
@@ -1,29 +1 @@
1
- // Barrel for observability types. Split into span/metric/provider files so
2
- // each module stays focused; consumers still import from "./types".
3
-
4
- export type {
5
- Counter,
6
- Gauge,
7
- Histogram,
8
- Meter,
9
- MetricDefinition,
10
- MetricLabels,
11
- MetricsHandle,
12
- MetricType,
13
- } from "./metric";
14
- export type {
15
- ObservabilityOptions,
16
- ObservabilityProvider,
17
- SamplingConfig,
18
- SensitiveFilterConfig,
19
- } from "./provider";
20
- export type {
21
- SerializedTraceContext,
22
- Span,
23
- SpanAttributes,
24
- SpanAttributeValue,
25
- SpanKind,
26
- SpanStatus,
27
- StartSpanOptions,
28
- Tracer,
29
- } from "./span";
1
+ export * from "@cosmicdrift/kumiko-types/observability";