@cosmicdrift/kumiko-framework 0.164.0 → 0.165.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +62 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +326 -18
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +146 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
  13. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  14. package/src/api/__tests__/sse-broker.test.ts +57 -0
  15. package/src/api/__tests__/sse-route.test.ts +4 -0
  16. package/src/api/auth-routes.ts +178 -33
  17. package/src/api/index.ts +1 -0
  18. package/src/api/jwt.ts +22 -1
  19. package/src/api/routes.ts +117 -30
  20. package/src/api/server.ts +39 -7
  21. package/src/api/sse-broker.ts +39 -0
  22. package/src/bun-db/connection.ts +3 -3
  23. package/src/bun-db/index.ts +1 -0
  24. package/src/bun-db/query.ts +12 -3
  25. package/src/consumer-cli.ts +60 -13
  26. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +19 -13
  27. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  28. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  29. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  30. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  31. package/src/db/api.ts +2 -2
  32. package/src/db/bun-provider.ts +2 -2
  33. package/src/db/connection.ts +6 -3
  34. package/src/db/dialect.ts +1 -6
  35. package/src/db/entity-table-meta-types.ts +1 -1
  36. package/src/db/event-store-executor-context.ts +2 -3
  37. package/src/db/event-store-executor-read.ts +2 -3
  38. package/src/db/event-store-executor-write.ts +8 -0
  39. package/src/db/index.ts +8 -3
  40. package/src/db/located-timestamp.ts +4 -0
  41. package/src/db/migrate-runner.ts +107 -11
  42. package/src/db/pg-error.ts +8 -0
  43. package/src/db/postgres-provider.ts +2 -2
  44. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  45. package/src/db/queries/ddl.ts +45 -0
  46. package/src/db/queries/event-store.ts +97 -21
  47. package/src/db/queries/test-stack.ts +4 -30
  48. package/src/db/reference-data.ts +2 -3
  49. package/src/db/replay-migration-sql.ts +114 -12
  50. package/src/db/tenant-db.ts +2 -4
  51. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  52. package/src/engine/__tests__/engine.test.ts +30 -0
  53. package/src/engine/__tests__/registry.test.ts +36 -0
  54. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  55. package/src/engine/__tests__/store-table.test.ts +2 -2
  56. package/src/engine/boot-validator/entity-handler.ts +14 -25
  57. package/src/engine/boot-validator/nav.ts +5 -0
  58. package/src/engine/constants.ts +32 -6
  59. package/src/engine/create-app.ts +11 -0
  60. package/src/engine/effective-features.ts +12 -2
  61. package/src/engine/extensions/user-data.ts +12 -4
  62. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  63. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  64. package/src/engine/feature-ui-extensions.ts +2 -2
  65. package/src/engine/hook-helpers.ts +3 -1
  66. package/src/engine/index.ts +1 -1
  67. package/src/engine/ownership.ts +4 -3
  68. package/src/engine/registry-ingest.ts +14 -14
  69. package/src/engine/registry-state.ts +4 -1
  70. package/src/engine/registry-validate.ts +7 -2
  71. package/src/engine/schema-builder.ts +1 -0
  72. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  73. package/src/engine/steps/_duration-utils.ts +2 -0
  74. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  75. package/src/engine/types/config.ts +1 -1
  76. package/src/engine/types/define-handler.ts +1 -1
  77. package/src/engine/types/entity-handlers.ts +1 -1
  78. package/src/engine/types/event-type-map.ts +1 -1
  79. package/src/engine/types/feature.ts +1 -1
  80. package/src/engine/types/fields.ts +1 -1
  81. package/src/engine/types/handlers.ts +1 -1
  82. package/src/engine/types/hooks.ts +1 -1
  83. package/src/engine/types/http-route.ts +1 -1
  84. package/src/engine/types/index.ts +0 -2
  85. package/src/engine/types/nav.ts +1 -1
  86. package/src/engine/types/ownership.ts +1 -1
  87. package/src/engine/types/projection.ts +1 -1
  88. package/src/engine/types/relations.ts +1 -1
  89. package/src/engine/types/screen.ts +1 -1
  90. package/src/engine/types/step.ts +1 -1
  91. package/src/engine/types/target-ref.ts +1 -1
  92. package/src/engine/types/tree-node.ts +1 -1
  93. package/src/engine/types/workspace.ts +1 -1
  94. package/src/engine/validate-projection-allowlist.ts +5 -5
  95. package/src/errors/classes.ts +21 -0
  96. package/src/errors/index.ts +1 -0
  97. package/src/errors/write-error-info.ts +6 -2
  98. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  99. package/src/event-store/__tests__/event-store.integration.test.ts +60 -14
  100. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +27 -12
  101. package/src/event-store/admin-api.ts +11 -4
  102. package/src/event-store/event-store.ts +20 -21
  103. package/src/event-store/index.ts +0 -1
  104. package/src/event-store/types.ts +1 -1
  105. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  106. package/src/files/__tests__/local-provider.test.ts +31 -0
  107. package/src/files/__tests__/write-stream.test.ts +3 -3
  108. package/src/files/index.ts +1 -1
  109. package/src/files/local-provider.ts +6 -1
  110. package/src/files/types.ts +8 -1
  111. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  112. package/src/jobs/job-runner.ts +41 -11
  113. package/src/logging/types.ts +1 -1
  114. package/src/observability/index.ts +1 -0
  115. package/src/observability/standard-metrics.ts +35 -2
  116. package/src/observability/types/index.ts +1 -1
  117. package/src/observability/types/metric.ts +1 -1
  118. package/src/observability/types/provider.ts +1 -1
  119. package/src/observability/types/span.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +203 -0
  121. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  122. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  123. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  124. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +111 -88
  125. package/src/pipeline/dispatch-shared.ts +59 -6
  126. package/src/pipeline/dispatch-stream.ts +44 -17
  127. package/src/pipeline/dispatcher.ts +7 -1
  128. package/src/pipeline/event-consumer-state.ts +16 -13
  129. package/src/pipeline/event-dispatcher-delivery.ts +22 -7
  130. package/src/pipeline/event-dispatcher.ts +22 -0
  131. package/src/pipeline/index.ts +2 -0
  132. package/src/pipeline/system-hooks.ts +137 -1
  133. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  134. package/src/rate-limit/resolver.ts +6 -2
  135. package/src/schema-cli.ts +24 -12
  136. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  137. package/src/search/reindex-entity.ts +31 -2
  138. package/src/search/types.ts +1 -1
  139. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  140. package/src/stack/db.ts +2 -1
  141. package/src/stack/push-entity-projection-tables.ts +2 -1
  142. package/src/stack/request-helper.ts +20 -1
  143. package/src/stack/table-helpers.ts +6 -4
  144. package/src/stack/test-stack.ts +18 -15
  145. package/src/testing/__tests__/late-bound.test.ts +7 -0
  146. package/src/testing/__tests__/wait-for.test.ts +6 -0
  147. package/src/testing/file-provider-contract.ts +26 -6
  148. package/src/testing/index.ts +1 -0
  149. package/src/testing/late-bound.ts +5 -3
  150. package/src/testing/wait-for.ts +3 -0
  151. package/src/testing/without-ambient-temporal.ts +14 -0
  152. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  153. package/src/time/geo-tz.ts +1 -1
  154. package/src/time/polyfill.ts +28 -39
  155. package/src/time/tz-context.ts +30 -24
  156. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  157. package/src/utils/safe-json.ts +3 -2
  158. package/src/db/__tests__/encryption.test.ts +0 -39
  159. package/src/db/encryption.ts +0 -45
  160. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -426,14 +426,14 @@ export function buildUiExtensionsMethods<TName extends string>(
426
426
  // EntityTableMeta uses snake_case for tableName (matches Postgres
427
427
  // convention); we just guard against truly broken input.
428
428
  throw new Error(
429
- `[Feature ${name}] Raw-table name "${tableName}" must be a ` +
429
+ `[Feature ${name}] Store-table name "${tableName}" must be a ` +
430
430
  `valid identifier (lowercase letters, digits, underscores; start with a letter).`,
431
431
  );
432
432
  }
433
433
  if (state.storeTables[tableName]) {
434
434
  throw new Error(
435
435
  `[Feature ${name}] r.storeTable("${tableName}") already registered. ` +
436
- `Raw-table names must be unique per feature.`,
436
+ `Store-table names must be unique per feature.`,
437
437
  );
438
438
  }
439
439
  // `read_` is reserved for r.entity()/r.projection() (managed,
@@ -8,7 +8,9 @@
8
8
  // are logged but don't affect the write. Use for: external systems
9
9
  // (SSE broadcast, search index, email, webhooks).
10
10
 
11
+ import type { HookPhase } from "@cosmicdrift/kumiko-types/hook-phase";
12
+
11
13
  export const HookPhases = {
12
14
  inTransaction: "inTransaction",
13
15
  afterCommit: "afterCommit",
14
- } as const;
16
+ } as const satisfies Record<HookPhase, HookPhase>;
@@ -52,7 +52,7 @@ export { defineStep, getStep, listStepKinds } from "./define-step";
52
52
  export type { WorkflowDefinition, WorkflowInput, WorkflowTrigger } from "./define-workflow";
53
53
  export { computeDefinitionFingerprint, defineWorkflow } from "./define-workflow";
54
54
  export type { ToggleReader } from "./effective-features";
55
- export { computeEffectiveFeatures } from "./effective-features";
55
+ export { computeEffectiveFeatures, isToggleableFeature } from "./effective-features";
56
56
  export {
57
57
  createEntityExecutor,
58
58
  defineEntityCreateHandler,
@@ -17,6 +17,10 @@
17
17
  // Construction: use the `from(ref, column?)` helper. It returns a FromRule
18
18
  // ready to drop into an access map.
19
19
 
20
+ import {
21
+ KUMIKO_COLUMNS_SYMBOL,
22
+ KUMIKO_NAME_SYMBOL,
23
+ } from "@cosmicdrift/kumiko-types/schema-table-types";
20
24
  import { toSnakeCase } from "../db/table-builder";
21
25
  import type { SessionUser } from "./types";
22
26
 
@@ -221,9 +225,6 @@ export function userCanCreateFieldRow(
221
225
  const PASS_CLAUSE: OwnershipClause = { kind: "pass" };
222
226
  const EMPTY_CLAUSE: OwnershipClause = { kind: "empty" };
223
227
 
224
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
225
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
226
-
227
228
  function tableNameOf(table: unknown): string {
228
229
  if (table !== null && typeof table === "object") {
229
230
  const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
@@ -20,7 +20,7 @@ export function populateFeatureCore(state: RegistryState, feature: FeatureDefini
20
20
  state.entityMap.set(name, entity);
21
21
  const physical = resolveTableName(name, entity, feature.name);
22
22
  const clash = state.physicalTableOwners.get(physical);
23
- if (clash?.kind === "raw") {
23
+ if (clash?.kind === "store") {
24
24
  throw new Error(
25
25
  `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
26
26
  `collides with r.storeTable("${physical}") (feature "${clash.featureName}"). ` +
@@ -317,37 +317,37 @@ export function populateProjectionsAndTables(
317
317
  // statements that target the same physical table name. Two features
318
318
  // registering the same physical tableName would also race two CREATE
319
319
  // TABLE statements via migrate-runner.
320
- for (const [rawName, rawDef] of Object.entries(feature.storeTables ?? {})) {
321
- const existing = state.storeTableMap.get(rawName);
320
+ for (const [storeName, storeDef] of Object.entries(feature.storeTables ?? {})) {
321
+ const existing = state.storeTableMap.get(storeName);
322
322
  if (existing) {
323
323
  throw new Error(
324
- `Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
324
+ `Store-table "${storeName}" registered by both feature "${existing.featureName}" and ` +
325
325
  `"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
326
326
  );
327
327
  }
328
- const physicalClash = state.physicalTableOwners.get(rawName);
328
+ const physicalClash = state.physicalTableOwners.get(storeName);
329
329
  if (physicalClash?.kind === "entity") {
330
330
  throw new Error(
331
- `Raw-table "${rawName}" (feature "${feature.name}") collides with the physical ` +
331
+ `Store-table "${storeName}" (feature "${feature.name}") collides with the physical ` +
332
332
  `table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
333
- `Pick a different tableName — both would emit CREATE TABLE "${rawName}".`,
333
+ `Pick a different tableName — both would emit CREATE TABLE "${storeName}".`,
334
334
  );
335
335
  }
336
- const piiFields = rawDef.meta.piiSubjectFields ?? [];
337
- if (piiFields.length > 0 && !rawDef.piiEncryptedOnWrite) {
336
+ const piiFields = storeDef.meta.piiSubjectFields ?? [];
337
+ if (piiFields.length > 0 && !storeDef.piiEncryptedOnWrite) {
338
338
  throw new Error(
339
- `Raw-table "${rawName}" (feature "${feature.name}") has PII-annotated fields ` +
339
+ `Store-table "${storeName}" (feature "${feature.name}") has PII-annotated fields ` +
340
340
  `(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
341
341
  `Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
342
342
  `{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
343
343
  );
344
344
  }
345
- state.physicalTableOwners.set(rawName, {
346
- kind: "raw",
347
- owner: rawName,
345
+ state.physicalTableOwners.set(storeName, {
346
+ kind: "store",
347
+ owner: storeName,
348
348
  featureName: feature.name,
349
349
  });
350
- state.storeTableMap.set(rawName, { ...rawDef, featureName: feature.name });
350
+ state.storeTableMap.set(storeName, { ...storeDef, featureName: feature.name });
351
351
  }
352
352
  }
353
353
 
@@ -202,7 +202,10 @@ export type RegistryState = {
202
202
  multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
203
203
  multiStreamProjectionFeatureMap: Map<string, string>;
204
204
  storeTableMap: Map<string, StoreTableDef>;
205
- physicalTableOwners: Map<string, { kind: "entity" | "raw"; owner: string; featureName: string }>;
205
+ physicalTableOwners: Map<
206
+ string,
207
+ { kind: "entity" | "store"; owner: string; featureName: string }
208
+ >;
206
209
  authClaimsHooks: AuthClaimsHookDef[];
207
210
  claimKeyMap: Map<string, ClaimKeyDefinition>;
208
211
  screenMap: Map<string, ScreenDefinition>;
@@ -564,7 +564,11 @@ export function validateEntityHookTargets(
564
564
  }
565
565
 
566
566
  export function validateJobTriggers(state: RegistryState): void {
567
- // Validate: job event triggers must reference existing handlers.
567
+ // Validate: job event triggers must reference an existing write/query
568
+ // handler OR an existing r.defineEvent registration. The latter is
569
+ // delivered async via the job-trigger event-consumer (server.ts), not
570
+ // the synchronous write-handler dispatch path — see
571
+ // createJobTriggerEventConsumer in pipeline/system-hooks.ts.
568
572
  // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
569
573
  // auch wenn nur einer fehlt fail-fast.
570
574
  const allHandlers = allHandlerQns(state);
@@ -575,8 +579,9 @@ export function validateJobTriggers(state: RegistryState): void {
575
579
  for (const t of triggers) {
576
580
  const rawName = resolveName(t);
577
581
  if (allHandlers.has(rawName)) continue;
582
+ if (state.eventMap.has(rawName)) continue;
578
583
  throw new Error(
579
- `Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
584
+ `Job "${jobName}" triggers on "${rawName}" but no handler or event with that name exists`,
580
585
  );
581
586
  }
582
587
  }
@@ -97,6 +97,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
97
97
  let schema = z.number();
98
98
  if (field.integer) schema = schema.int();
99
99
  if (field.min !== undefined) schema = schema.min(field.min);
100
+ if (field.max !== undefined) schema = schema.max(field.max);
100
101
  return field.default !== undefined ? schema.default(field.default) : schema;
101
102
  }
102
103
  case "decimal": {
@@ -0,0 +1,20 @@
1
+ // kumiko-framework#1525: addDuration must not rely on globalThis.Temporal —
2
+ // it's a pure free function called from step handlers before boot may have
3
+ // run ensureTemporalPolyfill() on every code path.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { addDuration } from "../_duration-utils";
7
+
8
+ describe("addDuration — kumiko-framework#1525: no ambient Temporal global", () => {
9
+ test("adds a duration without relying on globalThis.Temporal", () => {
10
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
11
+ delete (globalThis as { Temporal?: unknown }).Temporal;
12
+ try {
13
+ const result = addDuration("2026-01-01T00:00:00Z", "P1DT1H");
14
+ expect(result).toBe("2026-01-02T01:00:00Z");
15
+ } finally {
16
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
17
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
18
+ }
19
+ });
20
+ });
@@ -2,6 +2,8 @@
2
2
  // Accepts "P1D", "PT1H", "P1Y2M3DT4H5M6S" etc.
3
3
  // Uses approximate calendar math (365d/year, 30d/month). See #23.
4
4
 
5
+ import { Temporal } from "temporal-polyfill";
6
+
5
7
  export function addDuration(baseIso: string, duration: string): string {
6
8
  const pattern = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
7
9
  const match = duration.match(pattern);
@@ -11,6 +11,7 @@
11
11
  // rejected by boot-validation — domain mutation MUST go through
12
12
  // r.step.aggregate.*.
13
13
 
14
+ import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
14
15
  import { extractTableName } from "../../db";
15
16
  import { executeRawQuery } from "../../db/queries/raw-sql";
16
17
  import { defineStep } from "../define-step";
@@ -23,10 +24,6 @@ type UnsafeProjectionUpsertArgs = {
23
24
  readonly row: StepResolver<Record<string, unknown>>;
24
25
  };
25
26
 
26
- // @cast-boundary drizzle-bridge — reads column snake_case names from
27
- // drizzle Symbol-based metadata without importing drizzle-orm.
28
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
29
-
30
27
  function resolveColumnName(table: unknown, field: string): string {
31
28
  if (typeof table !== "object" || table === null) return field;
32
29
  const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/config";
2
+ export type * from "@cosmicdrift/kumiko-types/config";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/define-handler";
2
+ export type * from "@cosmicdrift/kumiko-types/define-handler";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/entity-handlers";
2
+ export type * from "@cosmicdrift/kumiko-types/entity-handlers";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/event-type-map";
1
+ export type * from "@cosmicdrift/kumiko-types/event-type-map";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/feature";
2
+ export type * from "@cosmicdrift/kumiko-types/feature";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/fields";
2
+ export type * from "@cosmicdrift/kumiko-types/fields";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/handlers";
2
+ export type * from "@cosmicdrift/kumiko-types/handlers";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/hooks";
2
+ export type * from "@cosmicdrift/kumiko-types/hooks";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/http-route";
1
+ export type * from "@cosmicdrift/kumiko-types/http-route";
@@ -1,7 +1,5 @@
1
1
  // Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
2
2
  // runtime helpers below that stay framework-side.
3
- // Duplicate types (OnDeleteStrategy, ConfigScope, ConcurrencyMode, LifecycleHookType)
4
- // are defined ONLY in constants.ts — re-exported here for backwards compatibility.
5
3
 
6
4
  export type {
7
5
  ConfigAccessor,
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/nav";
2
+ export type * from "@cosmicdrift/kumiko-types/nav";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/ownership";
2
+ export type * from "@cosmicdrift/kumiko-types/ownership";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/projection";
2
+ export type * from "@cosmicdrift/kumiko-types/projection";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/relations";
1
+ export type * from "@cosmicdrift/kumiko-types/relations";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/screen";
2
+ export type * from "@cosmicdrift/kumiko-types/screen";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/step";
2
+ export type * from "@cosmicdrift/kumiko-types/step";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/target-ref";
1
+ export type * from "@cosmicdrift/kumiko-types/target-ref";
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/tree-node";
1
+ export type * from "@cosmicdrift/kumiko-types/tree-node";
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/workspace";
2
+ export type * from "@cosmicdrift/kumiko-types/workspace";
@@ -23,6 +23,10 @@
23
23
  // caught by this validator. A future lint-rule will enforce the contract
24
24
  // statically; today it lives in this comment + the StepBuilder doc.
25
25
 
26
+ import {
27
+ KUMIKO_META_SYMBOL,
28
+ KUMIKO_NAME_SYMBOL,
29
+ } from "@cosmicdrift/kumiko-types/schema-table-types";
26
30
  import { getStep } from "./define-step";
27
31
  import { buildPipelineSteps } from "./pipeline";
28
32
  import type { FeatureDefinition, SessionUser, TenantId, WriteEvent } from "./types";
@@ -55,13 +59,9 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
55
59
  }
56
60
  }
57
61
 
58
- // @cast-boundary drizzle-bridge — reads table name from a Symbol without
59
- // importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
60
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
61
62
  // table()/buildEntityTable spread column handles as enumerable props, so an
62
63
  // entity field named `tableName`/`source` would shadow the matching meta key —
63
- // the canonical meta under this symbol is the only collision-safe source.
64
- const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
64
+ // the canonical meta under KUMIKO_META_SYMBOL is the only collision-safe source.
65
65
 
66
66
  function resolveTableNameFromStep(table: unknown): string {
67
67
  if (typeof table === "object" && table !== null) {
@@ -138,6 +138,27 @@ export class VersionConflictError extends ConflictError {
138
138
  }
139
139
  }
140
140
 
141
+ // The caller retried a write with an idempotencyKey it had already used —
142
+ // the event-store's partial unique index on metadata.idempotencyKey caught
143
+ // the duplicate. Distinct from unique_violation/version_conflict: this is
144
+ // not an error the caller should retry, it means the earlier attempt
145
+ // already succeeded and this one is a no-op replay.
146
+ export class IdempotentReplayError extends ConflictError {
147
+ override readonly code: string = "idempotent_replay";
148
+
149
+ constructor(
150
+ details: { readonly idempotencyKey: string },
151
+ opts?: Pick<ErrorOpts, "i18nKey" | "cause">,
152
+ ) {
153
+ super({
154
+ message: `event with idempotencyKey "${details.idempotencyKey}" was already appended`,
155
+ i18nKey: opts?.i18nKey ?? "errors.idempotentReplay",
156
+ details,
157
+ ...(opts?.cause && { cause: opts.cause }),
158
+ });
159
+ }
160
+ }
161
+
141
162
  // Entity-level unique-index violation. Distinct from VersionConflictError:
142
163
  // version_conflict means "two writers raced on the events_aggregate_version
143
164
  // _uq index" (optimistic-concurrency); unique_violation means "you tried to
@@ -13,6 +13,7 @@ export {
13
13
  AccessDeniedError,
14
14
  ConflictError,
15
15
  FeatureDisabledError,
16
+ IdempotentReplayError,
16
17
  InternalError,
17
18
  NotFoundError,
18
19
  RateLimitError,
@@ -7,8 +7,12 @@ import { KumikoError } from "./kumiko-error";
7
7
  import { FrameworkReasons } from "./reasons";
8
8
  import { buildInvalidTransitionDetails } from "./transition-details";
9
9
 
10
- // Legacy types — re-exported for callers still importing this module directly.
11
- export * from "@cosmicdrift/kumiko-types/write-error-info-types";
10
+ // Re-exported so a caller importing this module directly (instead of via
11
+ // the `./errors` barrel, which already re-exports these by name) still
12
+ // gets the types — explicit, not a wildcard: this module isn't a public
13
+ // subpath export (see package.json `exports`), so the only readers are
14
+ // framework-internal.
15
+ export type { WriteErrorInfo, WriteFailure };
12
16
 
13
17
  // Convenience for call sites that return a failed WriteResult. Keeps the
14
18
  // pattern `return writeFailure(new NotFoundError(...))` compact so handlers
@@ -15,7 +15,7 @@ import { asRawClient, selectMany } from "../../db/query";
15
15
  import { createTestDb, type TestDb } from "../../stack";
16
16
  import { generateId as uuid } from "../../utils";
17
17
  import { appendRaw, appendRawBatch, type RawEventToAppend } from "../admin-api";
18
- import { VersionConflictError } from "../errors";
18
+ import { IdempotentAppendConflictError, VersionConflictError } from "../errors";
19
19
  import { append, loadAggregate } from "../event-store";
20
20
  import { createEventsTable, eventsTable } from "../events-schema";
21
21
 
@@ -187,6 +187,32 @@ describe("appendRaw — single event", () => {
187
187
  ).rejects.toBeInstanceOf(VersionConflictError);
188
188
  });
189
189
 
190
+ test("appendRaw distinguishes an idempotency-key replay from a version conflict (#1499)", async () => {
191
+ const idempotencyKey = uuid();
192
+ await appendRaw(
193
+ testDb.db,
194
+ makeEvent({
195
+ aggregateId: uuid(),
196
+ expectedVersion: 0,
197
+ metadata: { userId: userMigration, idempotencyKey },
198
+ }),
199
+ );
200
+
201
+ // A second, different aggregate reusing the same idempotencyKey hits
202
+ // the tenant-scoped partial unique index — distinct from a version
203
+ // race on the same aggregate, and must not be reported as one.
204
+ await expect(
205
+ appendRaw(
206
+ testDb.db,
207
+ makeEvent({
208
+ aggregateId: uuid(),
209
+ expectedVersion: 0,
210
+ metadata: { userId: userMigration, idempotencyKey },
211
+ }),
212
+ ),
213
+ ).rejects.toBeInstanceOf(IdempotentAppendConflictError);
214
+ });
215
+
190
216
  test("version_conflict on missing predecessor (appendRaw v=5 without v=1..4)", async () => {
191
217
  const aggregateId = uuid();
192
218
  // Try to write version=5 (expectedVersion=4) against an empty stream.
@@ -60,6 +60,38 @@ describe("event-store: append + load", () => {
60
60
  expect(events[0]?.metadata.userId).toBe(userA);
61
61
  });
62
62
 
63
+ test("kumiko-framework#1490: subsequent append doesn't rely on globalThis.Temporal", async () => {
64
+ const aggregateId = uuid();
65
+ const first = await append(testDb.db, {
66
+ aggregateId,
67
+ aggregateType: "task",
68
+ tenantId: tenantA,
69
+ expectedVersion: 0,
70
+ type: "task.created",
71
+ payload: { title: "Buy milk" },
72
+ metadata: { userId: userA },
73
+ });
74
+
75
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
76
+ delete (globalThis as { Temporal?: unknown }).Temporal;
77
+ try {
78
+ const second = await append(testDb.db, {
79
+ aggregateId,
80
+ aggregateType: "task",
81
+ tenantId: tenantA,
82
+ expectedVersion: first.version,
83
+ type: "task.updated",
84
+ payload: { title: "Buy oat milk" },
85
+ metadata: { userId: userA },
86
+ });
87
+ expect(second.version).toBe(2);
88
+ expect(second.createdAt).toBeDefined();
89
+ } finally {
90
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
91
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
92
+ }
93
+ });
94
+
63
95
  test("subsequent appends increment version and are ordered", async () => {
64
96
  const aggregateId = uuid();
65
97
  const base = {
@@ -200,20 +232,34 @@ describe("event-store: idempotency-key conflict", () => {
200
232
  });
201
233
 
202
234
  test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
203
- const events = await Promise.all(
204
- Array.from({ length: 3 }, () =>
205
- append(testDb.db, {
206
- aggregateId: uuid(),
207
- aggregateType: "task",
208
- tenantId: tenantA,
209
- expectedVersion: 0,
210
- type: "task.created",
211
- payload: {},
212
- metadata: { userId: userA },
213
- }),
214
- ),
215
- );
216
- expect(events).toHaveLength(3);
235
+ // Same tenant for both, unlike the different-aggregateId version this
236
+ // replaces Postgres treats every NULL as distinct even in a plain
237
+ // unique index, so this does NOT exercise the partial index's `WHERE
238
+ // ... IS NOT NULL` clause specifically (that's provable only by a
239
+ // duplicate NON-null key, covered above). What this does pin: a fully-
240
+ // omitted key and an explicit `idempotencyKey: undefined` both serialize
241
+ // to a JSON-absent key (JSON.stringify drops undefined) and must behave
242
+ // identically, rather than one silently colliding on `"idempotencyKey":null`.
243
+ const omitted = await append(testDb.db, {
244
+ aggregateId: uuid(),
245
+ aggregateType: "task",
246
+ tenantId: tenantA,
247
+ expectedVersion: 0,
248
+ type: "task.created",
249
+ payload: {},
250
+ metadata: { userId: userA },
251
+ });
252
+ const explicitUndefined = await append(testDb.db, {
253
+ aggregateId: uuid(),
254
+ aggregateType: "task",
255
+ tenantId: tenantA,
256
+ expectedVersion: 0,
257
+ type: "task.created",
258
+ payload: {},
259
+ metadata: { userId: userA, idempotencyKey: undefined },
260
+ });
261
+ expect(omitted.version).toBe(1);
262
+ expect(explicitUndefined.version).toBe(1);
217
263
  });
218
264
  });
219
265
 
@@ -1,22 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Glob } from "bun";
3
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
- ];
4
+ // getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
5
+ // it to probe whether a foreign tenant's aggregate exists (see event-store.ts
6
+ // SECURITY doc). Restricted to known seed/system-internal callers; extend
7
+ // only for genuine new ones.
8
+ const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
12
9
 
13
10
  const ALLOWED_FILES = new Set([
14
11
  "packages/framework/src/event-store/event-store.ts",
15
12
  "packages/framework/src/event-store/index.ts",
16
13
  "packages/bundled-features/src/tenant/seeding.ts",
17
14
  "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
15
  "packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
21
16
  ]);
22
17
 
@@ -24,11 +19,31 @@ const REPO_ROOT = `${import.meta.dir}/../../../../..`;
24
19
 
25
20
  describe("unscoped stream primitives — caller allowlist", () => {
26
21
  test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
27
- const glob = new Glob("packages/{framework,bundled-features}/src/**/*.ts");
22
+ // Widened from {framework,bundled-features} — the primitives are exported
23
+ // from the public @cosmicdrift/kumiko-framework/event-store barrel, so any
24
+ // package (server-runtime, dispatcher-live, headless, renderer-web, cli, ...)
25
+ // can import and call them; the allowlist must cover all of them, not just
26
+ // the two packages that happened to have callers when this guard was written.
27
+ const glob = new Glob("packages/*/src/**/*.ts");
28
28
  const matches = new Set<string>();
29
29
  for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
30
30
  const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
31
- if (RESTRICTED_SYMBOLS.some((symbol) => content.includes(symbol))) {
31
+ // Strip full-line `//` comments before scanning — a bare
32
+ // `content.includes(symbol)` also matches a symbol only mentioned in
33
+ // prose (e.g. explaining why a helper isn't needed), which would force
34
+ // an unrelated file into the allowlist for a reference that isn't an
35
+ // actual import or call.
36
+ const code = content
37
+ .split("\n")
38
+ .filter((line) => !line.trim().startsWith("//"))
39
+ .join("\n");
40
+ const referencesSymbol = (symbol: string): boolean =>
41
+ new RegExp(`\\b${symbol}\\s*\\(`).test(code) ||
42
+ // Covers both `import { symbol } from "..."` and a re-export barrel
43
+ // (`export { symbol } from "./event-store"`) — a new barrel that
44
+ // surfaces the oracle primitive must widen the allowlist too.
45
+ new RegExp(`\\b(import|export)\\b[^;]*\\b${symbol}\\b`).test(code);
46
+ if (RESTRICTED_SYMBOLS.some(referencesSymbol)) {
32
47
  matches.add(relPath);
33
48
  }
34
49
  }