@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.
- package/package.json +7 -3
- package/src/api/server.ts +12 -2
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +70 -0
- package/src/bun-db/__tests__/where-patterns.integration.test.ts +28 -0
- package/src/bun-db/query.ts +84 -14
- package/src/db/__tests__/column-ddl.integration.test.ts +9 -0
- package/src/db/__tests__/migrate-generator.test.ts +23 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +61 -1
- package/src/db/dialect.ts +10 -0
- package/src/db/entity-table-meta.ts +3 -0
- package/src/db/migrate-generator.ts +20 -7
- package/src/db/table-builder.ts +25 -19
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +162 -0
- package/src/derivatives/__tests__/derivatives-context.test.ts +167 -0
- package/src/derivatives/__tests__/variant-key.test.ts +55 -0
- package/src/derivatives/derivatives-context.ts +146 -0
- package/src/derivatives/index.ts +3 -0
- package/src/derivatives/variant-key.ts +40 -0
- package/src/engine/__tests__/boot-validator.test.ts +277 -0
- package/src/engine/boot-validator/screens.ts +81 -17
- package/src/engine/extension-names.ts +17 -0
- package/src/engine/index.ts +1 -0
- package/src/errors/__tests__/classes.test.ts +39 -0
- package/src/errors/zod-bridge.ts +18 -1
- package/src/event-store/__tests__/perf.integration.test.ts +33 -17
- package/src/files/in-memory-provider.ts +9 -0
- package/src/jobs/job-runner.ts +19 -2
- package/src/pipeline/dispatch-shared.ts +10 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- 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
|
|
7
|
-
// - Read-Latency
|
|
8
|
-
// - Update-Latency
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
114
|
+
expect(p95).toBeLessThan(30);
|
|
102
115
|
});
|
|
103
116
|
|
|
104
|
-
test("read-latency
|
|
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
|
|
139
|
-
//
|
|
140
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
},
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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 &&
|
|
402
|
+
context._configAccessorFactory && tenantScopedDb
|
|
398
403
|
? context._configAccessorFactory({
|
|
399
404
|
user: { id: jobSystemUser.id, tenantId },
|
|
400
|
-
db:
|
|
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
|
+
});
|