@cosmicdrift/kumiko-framework 0.188.0 → 0.190.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +7 -3
  2. package/src/api/server.ts +12 -2
  3. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +70 -0
  4. package/src/bun-db/__tests__/where-patterns.integration.test.ts +28 -0
  5. package/src/bun-db/query.ts +84 -14
  6. package/src/db/__tests__/column-ddl.integration.test.ts +9 -0
  7. package/src/db/__tests__/migrate-generator.test.ts +23 -0
  8. package/src/db/__tests__/schema-migration.integration.test.ts +61 -1
  9. package/src/db/dialect.ts +10 -0
  10. package/src/db/entity-table-meta.ts +3 -0
  11. package/src/db/migrate-generator.ts +20 -7
  12. package/src/db/table-builder.ts +25 -19
  13. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +162 -0
  14. package/src/derivatives/__tests__/derivatives-context.test.ts +167 -0
  15. package/src/derivatives/__tests__/variant-key.test.ts +55 -0
  16. package/src/derivatives/derivatives-context.ts +146 -0
  17. package/src/derivatives/index.ts +3 -0
  18. package/src/derivatives/variant-key.ts +40 -0
  19. package/src/engine/__tests__/boot-validator.test.ts +277 -0
  20. package/src/engine/boot-validator/screens.ts +81 -17
  21. package/src/engine/extension-names.ts +17 -0
  22. package/src/engine/index.ts +1 -0
  23. package/src/errors/__tests__/classes.test.ts +39 -0
  24. package/src/errors/zod-bridge.ts +18 -1
  25. package/src/event-store/__tests__/perf.integration.test.ts +33 -17
  26. package/src/files/in-memory-provider.ts +9 -0
  27. package/src/jobs/job-runner.ts +19 -2
  28. package/src/pipeline/dispatch-shared.ts +10 -0
  29. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  30. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
@@ -3,16 +3,26 @@
3
3
  // the raw-SQL spike used as proof before the ES pivot.
4
4
  //
5
5
  // Targets (from docs/plans/architecture/event-sourcing-spike-1.md):
6
- // - Write-Latency p99 < 30ms (append a single event)
7
- // - Read-Latency p99 < 25ms (loadAggregate for a single aggregate)
8
- // - Update-Latency p99 < 30ms (append with predecessor-check WHERE EXISTS)
6
+ // - Write-Latency p95 < 30ms (append a single event)
7
+ // - Read-Latency p95 < 25ms (loadAggregate for a single aggregate)
8
+ // - Update-Latency p95 < 30ms (append with predecessor-check WHERE EXISTS)
9
9
  // - Snapshot-Load < 50ms (1000-event aggregate, snapshot @ 900)
10
10
  //
11
11
  // Workload is sequential against local Docker Postgres — no network
12
12
  // latency, single-node PG. Production deploys are slower; these numbers
13
- // are the ceiling. Red test = framework regression, no slack tolerated.
13
+ // are the ceiling.
14
14
  //
15
- // Isolated from bulk integration via `bun run test:integration:perf`.
15
+ // Isolated from bulk integration via `bun run test:integration:perf`. Used
16
+ // to run inside the `integration` CI job, right after the ~213-test bulk
17
+ // suite, and flaked up to 3.4x under that (30-102ms vs the 25-30ms budgets
18
+ // above, #1940). Moved to its own `event-store-perf` CI job
19
+ // (test:integration:perf:eventstore) — but re-measuring against a fresh
20
+ // container per run (mirroring that job) showed the real cause wasn't job
21
+ // contention: p50 sits at 1-3ms in every run, and single-sample p99 spikes
22
+ // to 47-73ms even fully isolated on an idle machine, from cold-Postgres
23
+ // connection/cache warm-up. Gate switched from p99 (the single worst-of-200
24
+ // sample) to p95 (drops the top 10), which absorbs that cold-start outlier
25
+ // while still catching a real order-of-magnitude regression.
16
26
 
17
27
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
28
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
@@ -60,7 +70,7 @@ async function measure<T>(op: () => Promise<T>): Promise<number> {
60
70
  }
61
71
 
62
72
  describe("event-store performance — Gate A", () => {
63
- test("write-latency p99 < 30ms over 200 sequential appends", async () => {
73
+ test("write-latency p95 < 30ms over 200 sequential appends", async () => {
64
74
  const samples: number[] = [];
65
75
 
66
76
  // Warm-up — Connection-Pool + Drizzle-Prepare-Overhead
@@ -95,13 +105,16 @@ describe("event-store performance — Gate A", () => {
95
105
 
96
106
  samples.sort((a, b) => a - b);
97
107
  const p50 = percentile(samples, 0.5);
108
+ const p95 = percentile(samples, 0.95);
98
109
  const p99 = percentile(samples, 0.99);
99
- console.log(` Write-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`);
110
+ console.log(
111
+ ` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
112
+ );
100
113
 
101
- expect(p99).toBeLessThan(30);
114
+ expect(p95).toBeLessThan(30);
102
115
  });
103
116
 
104
- test("read-latency p99 < 25ms for loadAggregate detail reads", async () => {
117
+ test("read-latency p95 < 25ms for loadAggregate detail reads", async () => {
105
118
  // Seed 200 single-event aggregates
106
119
  const ids: string[] = [];
107
120
  for (let i = 0; i < 200; i++) {
@@ -130,18 +143,18 @@ describe("event-store performance — Gate A", () => {
130
143
 
131
144
  samples.sort((a, b) => a - b);
132
145
  const p50 = percentile(samples, 0.5);
146
+ const p95 = percentile(samples, 0.95);
133
147
  const p99 = percentile(samples, 0.99);
134
148
  console.log(
135
- ` Read-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=${ids.length})`,
149
+ ` Read-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=${ids.length})`,
136
150
  );
137
151
 
138
- // 25ms statt der 10ms aus dem Spike-Doc: der shared cdgs-runner failt
139
- // lastabhängig (real gemessen 13.7ms p99) als CI-Gate zählt die
140
- // Größenordnung, nicht der Idle-Bestwert. Tracking: #325.
141
- expect(p99).toBeLessThan(25);
152
+ // 25ms budget kept from the original spike doc's 10ms an
153
+ // order-of-magnitude gate, not an idle-best-case one. Tracking: #325.
154
+ expect(p95).toBeLessThan(25);
142
155
  });
143
156
 
144
- test("update-latency p99 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
157
+ test("update-latency p95 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
145
158
  // Single aggregate, repeated updates — the INSERT … SELECT … WHERE EXISTS
146
159
  // path is heavier than a simple create and adds an index lookup.
147
160
  const aggregateId = uuid();
@@ -191,10 +204,13 @@ describe("event-store performance — Gate A", () => {
191
204
 
192
205
  samples.sort((a, b) => a - b);
193
206
  const p50 = percentile(samples, 0.5);
207
+ const p95 = percentile(samples, 0.95);
194
208
  const p99 = percentile(samples, 0.99);
195
- console.log(` Update-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`);
209
+ console.log(
210
+ ` Update-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
211
+ );
196
212
 
197
- expect(p99).toBeLessThan(30);
213
+ expect(p95).toBeLessThan(30);
198
214
  });
199
215
 
200
216
  test("snapshot-load < 50ms for 1000-event aggregate (Gate A)", async () => {
@@ -10,6 +10,11 @@ export type InMemoryFileProvider = FileStorageProvider & {
10
10
  // Test-only introspection: keys currently stored. Useful for assertions
11
11
  // like `expect(provider.keys()).toContain("tenant/foo.jpg")`.
12
12
  keys(): readonly string[];
13
+ // Test-only introspection: the mimeType a write()/writeStream() stored for
14
+ // a key — undefined mirrors an untracked write. FileStorageProvider has no
15
+ // read-back-metadata method (mimeType lives on the FileRef row in prod),
16
+ // so this is the only way a test can assert what actually landed in storage.
17
+ mimeTypeOf(key: string): string | undefined;
13
18
  // Test-only reset between cases. beforeEach-friendly.
14
19
  clear(): void;
15
20
  };
@@ -93,6 +98,10 @@ export function createInMemoryFileProvider(): InMemoryFileProvider {
93
98
  return Array.from(store.keys());
94
99
  },
95
100
 
101
+ mimeTypeOf(key) {
102
+ return store.get(key)?.mimeType;
103
+ },
104
+
96
105
  clear() {
97
106
  store.clear();
98
107
  },
@@ -3,6 +3,7 @@ import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
5
5
  import { createTenantDb } from "../db/tenant-db";
6
+ import { createDerivativesContext } from "../derivatives/derivatives-context";
6
7
  import { createSystemUser } from "../engine/system-user";
7
8
  import {
8
9
  type AppContext,
@@ -393,17 +394,33 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
393
394
  // at runtime the write-handler path never would (framework#1532).
394
395
  const notify = context._notifyFactory?.(jobSystemUser, tenantId);
395
396
  const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
397
+ // Shared by the config accessor and ctx.derivatives below — both need the
398
+ // same tenant-scoped db, and building it twice would let the two calls
399
+ // drift apart.
400
+ const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
396
401
  const config =
397
- context._configAccessorFactory && configDb
402
+ context._configAccessorFactory && tenantScopedDb
398
403
  ? context._configAccessorFactory({
399
404
  user: { id: jobSystemUser.id, tenantId },
400
- db: createTenantDb(configDb, tenantId, "system"),
405
+ db: tenantScopedDb,
401
406
  secrets: context.secrets,
402
407
  })
403
408
  : undefined;
409
+ // Mirror dispatch-shared.ts: ctx.derivatives needs files+db, same
410
+ // tenant-scoped db the config accessor above uses.
411
+ const derivatives =
412
+ files && tenantScopedDb
413
+ ? createDerivativesContext({
414
+ files,
415
+ registry,
416
+ db: tenantScopedDb,
417
+ tenantId,
418
+ })
419
+ : context.derivatives;
404
420
  const jobContext: AppContext = {
405
421
  ...context,
406
422
  files,
423
+ derivatives,
407
424
  ...(notify !== undefined && { notify }),
408
425
  ...(config !== undefined && { config }),
409
426
  // The runner owns the registry it resolved this job from — expose it so
@@ -4,6 +4,7 @@ import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
5
5
  import type { buildEntityTable } from "../db/table-builder";
6
6
  import { createTenantDb } from "../db/tenant-db";
7
+ import { createDerivativesContext } from "../derivatives/derivatives-context";
7
8
  import type { defineTransitions } from "../engine/state-machine";
8
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
9
10
  import type {
@@ -209,6 +210,14 @@ export async function buildHandlerContext(
209
210
  // to a statically-injected context.files (tests).
210
211
  const fileResolver = context._fileProviderResolver;
211
212
  const files = fileResolver ? createFileContext(() => fileResolver(user.tenantId)) : context.files;
213
+ // ctx.derivatives builds on ctx.files (needs a FileContext to read the
214
+ // original + write the variant) plus db (to look up the FileRef row) — so
215
+ // it can only be constructed exactly when files+db both resolved; falls
216
+ // back to a statically-injected context.derivatives otherwise (tests).
217
+ const derivatives =
218
+ files && db
219
+ ? createDerivativesContext({ files, registry, db, tenantId: user.tenantId })
220
+ : context.derivatives;
212
221
 
213
222
  // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
214
223
  // resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
@@ -565,6 +574,7 @@ export async function buildHandlerContext(
565
574
  notify,
566
575
  ...(config && { config }),
567
576
  ...(files && { files }),
577
+ ...(derivatives && { derivatives }),
568
578
  // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
569
579
  // a handler actually starts building its write — bound here so entity
570
580
  // CRUD handlers (entity-handlers.ts) can forward it to the executor
@@ -1,3 +1,4 @@
1
+ import type { DerivativesContext } from "@cosmicdrift/kumiko-types/derivatives-types";
1
2
  import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
2
3
  import type { DbRunner } from "../db/connection";
3
4
  import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
@@ -29,6 +30,8 @@ export type MultiStreamApplyContextDeps = {
29
30
  // Same FileContext the outer AppContext carries, passed through so
30
31
  // MSP applies can reach binaries without another wiring indirection.
31
32
  readonly files?: FileContext;
33
+ // Same DerivativesContext the outer AppContext carries — mirrors `files`.
34
+ readonly derivatives?: DerivativesContext;
32
35
  };
33
36
 
34
37
  export function createMultiStreamApplyContext(
@@ -36,6 +39,7 @@ export function createMultiStreamApplyContext(
36
39
  ): MultiStreamApplyContext {
37
40
  return {
38
41
  ...(deps.files ? { files: deps.files } : {}),
42
+ ...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
39
43
  appendEvent: (async (args: AppendEventArgs) => {
40
44
  await appendDomainEventCore(
41
45
  {
@@ -12,3 +12,17 @@ describe("stringifyJson — Temporal.Instant without ambient Temporal", () => {
12
12
  });
13
13
  });
14
14
  });
15
+
16
+ describe("stringifyJson — Temporal.PlainDate (kumiko-framework#1924)", () => {
17
+ test("serializes to yyyy-mm-dd via PlainDate's own toJSON(), no special-casing needed", () => {
18
+ const day = PolyfillTemporal.PlainDate.from("2026-03-15");
19
+ expect(stringifyJson({ publishedAt: day })).toBe('{"publishedAt":"2026-03-15"}');
20
+ });
21
+
22
+ test("serializes polyfill PlainDate when globalThis.Temporal is missing", async () => {
23
+ const day = PolyfillTemporal.PlainDate.from("2026-03-15");
24
+ await withoutAmbientTemporal(() => {
25
+ expect(stringifyJson({ publishedAt: day })).toBe('{"publishedAt":"2026-03-15"}');
26
+ });
27
+ });
28
+ });