@cosmicdrift/kumiko-framework 0.159.1 → 0.161.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 (90) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +65 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  7. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  8. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  9. package/src/api/api-constants.ts +1 -0
  10. package/src/api/auth-middleware.ts +17 -44
  11. package/src/api/auth-routes.ts +6 -2
  12. package/src/api/index.ts +1 -0
  13. package/src/api/routes.ts +57 -0
  14. package/src/api/server.ts +5 -4
  15. package/src/bun-db/query.ts +12 -25
  16. package/src/crypto/kms-adapter.ts +2 -118
  17. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  18. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  19. package/src/db/cursor.ts +1 -18
  20. package/src/db/dialect.ts +8 -19
  21. package/src/db/entity-table-meta-types.ts +2 -92
  22. package/src/db/event-store-executor.ts +4 -96
  23. package/src/db/table-builder.ts +2 -19
  24. package/src/db/tenant-db.ts +6 -55
  25. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  26. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  27. package/src/engine/__tests__/engine.test.ts +28 -0
  28. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  29. package/src/engine/__tests__/registry.test.ts +40 -0
  30. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  31. package/src/engine/boot-validator/entity-handler.ts +10 -1
  32. package/src/engine/define-feature.ts +1 -0
  33. package/src/engine/define-handler.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  35. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  36. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  37. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  38. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  39. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  40. package/src/engine/feature-ast/extractors/index.ts +1 -0
  41. package/src/engine/feature-ast/index.ts +2 -0
  42. package/src/engine/feature-ast/parse.ts +3 -0
  43. package/src/engine/feature-ast/patch.ts +2 -0
  44. package/src/engine/feature-ast/patcher.ts +21 -0
  45. package/src/engine/feature-ast/patterns.ts +16 -0
  46. package/src/engine/feature-ast/render.ts +15 -0
  47. package/src/engine/feature-builder-state.ts +3 -0
  48. package/src/engine/feature-entity-handlers.ts +35 -1
  49. package/src/engine/index.ts +3 -0
  50. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  51. package/src/engine/pattern-library/library.ts +2 -0
  52. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  53. package/src/engine/registry-facade.ts +9 -0
  54. package/src/engine/registry-ingest.ts +10 -0
  55. package/src/engine/registry-state.ts +3 -0
  56. package/src/engine/types/config.ts +2 -497
  57. package/src/engine/types/define-handler.ts +2 -94
  58. package/src/engine/types/entity-handlers.ts +2 -30
  59. package/src/engine/types/feature.ts +2 -1021
  60. package/src/engine/types/fields.ts +2 -685
  61. package/src/engine/types/handlers.ts +2 -820
  62. package/src/engine/types/hooks.ts +2 -170
  63. package/src/engine/types/index.ts +44 -36
  64. package/src/engine/types/nav.ts +2 -67
  65. package/src/engine/types/ownership.ts +2 -83
  66. package/src/engine/types/projection.ts +2 -165
  67. package/src/engine/types/screen.ts +2 -747
  68. package/src/engine/types/step.ts +2 -334
  69. package/src/engine/types/workspace.ts +2 -42
  70. package/src/errors/write-error-info.ts +6 -22
  71. package/src/event-store/errors.ts +2 -35
  72. package/src/event-store/event-store.ts +2 -21
  73. package/src/event-store/snapshot.ts +11 -35
  74. package/src/event-store/types.ts +2 -22
  75. package/src/files/provider-resolver.ts +3 -5
  76. package/src/files/types.ts +5 -54
  77. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  78. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  79. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  80. package/src/pipeline/dispatch-shared.ts +39 -1
  81. package/src/pipeline/dispatch-stream.ts +74 -0
  82. package/src/pipeline/dispatcher-utils.ts +1 -1
  83. package/src/pipeline/dispatcher.ts +7 -0
  84. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  85. package/src/rate-limit/resolver.ts +10 -30
  86. package/src/secrets/envelope-cipher.ts +4 -6
  87. package/src/secrets/types.ts +2 -177
  88. package/src/stack/request-helper.ts +19 -2
  89. package/src/stack/test-stack.ts +33 -14
  90. package/src/time/tz-context.ts +9 -56
@@ -598,13 +598,17 @@ export function createAuthRoutes(
598
598
  const data = result.data as
599
599
  | { kind: "auth-session"; session: SessionUser }
600
600
  | { kind: "mfa-challenge"; challengeToken: string }
601
- | { kind: "mfa-setup-required" };
601
+ | { kind: "mfa-setup-required"; preauthSetupToken: string };
602
602
 
603
603
  if (data.kind === "mfa-setup-required") {
604
604
  // No session, no challenge — the client must show an
605
605
  // enrollment-required message. No rate-limit reset (same reasoning
606
606
  // as the mfa-challenge branch below).
607
- return c.json({ isSuccess: true, mfaSetupRequired: true });
607
+ return c.json({
608
+ isSuccess: true,
609
+ mfaSetupRequired: true,
610
+ preauthSetupToken: data.preauthSetupToken,
611
+ });
608
612
  }
609
613
 
610
614
  if (data.kind === "mfa-challenge") {
package/src/api/index.ts CHANGED
@@ -2,6 +2,7 @@ export type { SetTenantCookieOptions } from "./anonymous-cookie";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
3
3
  export type {
4
4
  AnonymousAccessConfig,
5
+ AnonymousAccessResolved,
5
6
  AuthMiddlewareOptions,
6
7
  AuthSessionChecker,
7
8
  AuthSessionStatus,
package/src/api/routes.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Context, Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
2
3
  import type { ContentfulStatusCode } from "hono/utils/http-status";
3
4
  import type { SessionUser } from "../engine/types/handlers";
4
5
  import {
@@ -16,6 +17,7 @@ import { Routes } from "./api-constants";
16
17
  import { getUser } from "./auth-middleware";
17
18
  import { patAllows } from "./pat-scope";
18
19
  import { requestContext } from "./request-context";
20
+ import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
19
21
 
20
22
  export function createApiRoutes(dispatcher: Dispatcher) {
21
23
  const api = new Hono();
@@ -121,6 +123,61 @@ export function createApiRoutes(dispatcher: Dispatcher) {
121
123
  }
122
124
  });
123
125
 
126
+ // Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
127
+ // broker-based /sse route). Frame contract for clients: "chunk" (one per
128
+ // yielded value, JSON-encoded), "ping" (heartbeat, empty data), "done"
129
+ // (terminal, empty data), "error" (terminal, JSON error envelope — the
130
+ // response status stays 200 since SSE headers are already flushed before
131
+ // dispatch gates run on the generator's first pull).
132
+ api.post(Routes.stream, async (c) => {
133
+ const user = getUser(c);
134
+ const body = await c.req.json<{ type: string; payload: unknown }>();
135
+ const requestId = requestContext.get()?.requestId;
136
+
137
+ try {
138
+ assertPatAllowed(user, body.type);
139
+ } catch (e) {
140
+ return queryErrorResponse(c, toKumiko(e), body.type);
141
+ }
142
+
143
+ return streamSSE(c, async (stream) => {
144
+ const generator = dispatcher.stream(body.type, body.payload, user);
145
+ stream.onAbort(() => {
146
+ void generator.return(undefined);
147
+ });
148
+
149
+ try {
150
+ let pending = generator.next();
151
+ while (true) {
152
+ let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
153
+ const heartbeat = new Promise<"heartbeat">((resolve) => {
154
+ heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
155
+ });
156
+ let outcome: Awaited<typeof pending> | "heartbeat";
157
+ try {
158
+ outcome = await Promise.race([pending, heartbeat]);
159
+ } finally {
160
+ clearTimeout(heartbeatTimer);
161
+ }
162
+
163
+ if (outcome === "heartbeat") {
164
+ await stream.writeSSE({ event: "ping", data: "" });
165
+ continue;
166
+ }
167
+ if (outcome.done) break;
168
+ await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
169
+ pending = generator.next();
170
+ }
171
+ await stream.writeSSE({ event: "done", data: "" });
172
+ } catch (e) {
173
+ const err = toKumiko(e);
174
+ logServerFault(err, requestId, body.type);
175
+ const { error } = serializeError(err, requestId);
176
+ await stream.writeSSE({ event: "error", data: stringifyJson(error) });
177
+ }
178
+ });
179
+ });
180
+
124
181
  return api;
125
182
  }
126
183
 
package/src/api/server.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  import type { SearchAdapter } from "../search/types";
48
48
  import { assertUnreachable, generateId } from "../utils";
49
49
  import { PUBLIC_API_PATHS } from "./api-constants";
50
- import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
50
+ import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
51
51
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
52
52
  import { csrfMiddleware } from "./csrf-middleware";
53
53
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
@@ -201,9 +201,10 @@ export type ServerOptions = {
201
201
  instanceId?: string;
202
202
  // Opt-in: serve unauthenticated requests on handlers that allow
203
203
  // roles=["anonymous"]. When omitted, every /api/* request still requires
204
- // a valid JWT (status quo). See AnonymousAccessConfig for the resolution
205
- // chain (header cookie resolver → defaultTenantId).
206
- anonymousAccess?: AnonymousAccessConfig;
204
+ // a valid JWT (status quo). App-facing config is AnonymousAccessConfig
205
+ // (defaultTenantId only); run{Prod,Dev}App merge auth-foundation tenant
206
+ // providers into AnonymousAccessResolved before calling buildServer.
207
+ anonymousAccess?: AnonymousAccessResolved;
207
208
  };
208
209
 
209
210
  export type KumikoServer = {
@@ -19,6 +19,11 @@
19
19
  // drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
20
20
  // reference, NICHT als runtime-API-call)
21
21
 
22
+ import type {
23
+ SelectOptions,
24
+ WhereObject,
25
+ WhereOperator,
26
+ } from "@cosmicdrift/kumiko-types/where-clause-types";
22
27
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
23
28
  import type { EntityTableMeta } from "../db/entity-table-meta";
24
29
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
@@ -183,19 +188,13 @@ function assertNotTenantScoped(db: unknown, fnName: string): void {
183
188
 
184
189
  export type AnyDb = BunDbRunner | unknown;
185
190
 
186
- // WhereValue: primitive für eq, array für IN, null für IS NULL, oder
187
- // operator-object für range/comparisons.
188
- export type WhereOperator = {
189
- readonly gt?: unknown;
190
- readonly gte?: unknown;
191
- readonly lt?: unknown;
192
- readonly lte?: unknown;
193
- readonly ne?: unknown;
194
- readonly in?: readonly unknown[];
195
- readonly like?: string;
196
- };
197
- export type WhereValue = unknown | WhereOperator;
198
- export type WhereObject = Record<string, WhereValue>;
191
+ export type {
192
+ OrderByClause,
193
+ SelectOptions,
194
+ WhereObject,
195
+ WhereOperator,
196
+ WhereValue,
197
+ } from "@cosmicdrift/kumiko-types/where-clause-types";
199
198
 
200
199
  function isWhereOperator(v: unknown): v is WhereOperator {
201
200
  if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
@@ -206,18 +205,6 @@ function isWhereOperator(v: unknown): v is WhereOperator {
206
205
  const opKeys = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
207
206
  return keys.every((k) => opKeys.includes(k));
208
207
  }
209
- export type OrderByClause = {
210
- readonly col: string;
211
- readonly direction?: "asc" | "desc";
212
- };
213
-
214
- export type SelectOptions = {
215
- readonly limit?: number;
216
- // Single column or array for multi-column tie-breaks (e.g.
217
- // [{col: "createdAt"}, {col: "id"}] for chronological-with-stable-id).
218
- readonly orderBy?: OrderByClause | readonly OrderByClause[];
219
- };
220
-
221
208
  // Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
222
209
  // biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
223
210
  type TableLike = EntityTableMeta | any;
@@ -1,118 +1,2 @@
1
- import type { TenantId } from "../engine/types/identifiers";
2
-
3
- // The subject a DEK belongs to. User data is shredded on user-forget,
4
- // tenant data on tenant-destroy — two erase triggers, two subject kinds.
5
- export type SubjectId =
6
- | { readonly kind: "user"; readonly userId: string }
7
- | { readonly kind: "tenant"; readonly tenantId: TenantId };
8
-
9
- // Compact storage key ("user:<uuid>" / "tenant:<uuid>") — primary key in
10
- // adapter backends and cache key in the request-level DEK cache.
11
- export type SubjectKey = string;
12
-
13
- export function subjectKeyForUser(userId: string): SubjectKey {
14
- return `user:${userId}`;
15
- }
16
-
17
- export function subjectKeyForTenant(tenantId: TenantId): SubjectKey {
18
- return `tenant:${tenantId}`;
19
- }
20
-
21
- export function subjectIdToKey(subject: SubjectId): SubjectKey {
22
- return subject.kind === "user"
23
- ? subjectKeyForUser(subject.userId)
24
- : subjectKeyForTenant(subject.tenantId);
25
- }
26
-
27
- export function subjectIdFromKey(key: SubjectKey): SubjectId {
28
- if (key.startsWith("user:")) return { kind: "user", userId: key.slice("user:".length) };
29
- if (key.startsWith("tenant:")) {
30
- return { kind: "tenant", tenantId: key.slice("tenant:".length) as TenantId }; // @cast-boundary parse of a key this module minted
31
- }
32
- throw new Error(`Invalid subject key: ${key}`);
33
- }
34
-
35
- export interface KmsContext {
36
- readonly tenantId?: TenantId;
37
- readonly requestId: string;
38
- readonly userId?: string;
39
- readonly eraseReason?: string;
40
- }
41
-
42
- export interface KmsHealth {
43
- readonly ok: boolean;
44
- readonly latencyMs: number;
45
- readonly details?: Record<string, unknown>;
46
- }
47
-
48
- // 32-byte AES-256 data-encryption key, unwrapped and ready for local use.
49
- export type SubjectDek = Buffer;
50
-
51
- interface KmsAdapterBase {
52
- /**
53
- * Creates a fresh subject key. Throws KeyAlreadyExistsError when the
54
- * subject already has one — including an erased tombstone: a shredded
55
- * subject must never get a new key, or forget could be undone by
56
- * re-encrypting under it.
57
- */
58
- createKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
59
-
60
- /**
61
- * Erases the key material immediately; the tombstone row stays for the
62
- * audit trail. Idempotent — repeat calls and unknown subjects are no-ops.
63
- */
64
- eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
65
-
66
- /** Probe for boot + readiness. Throws when the backend is unreachable. */
67
- health(): Promise<KmsHealth>;
68
- }
69
-
70
- // Backends that hand out the plaintext DEK (Pg, InMemory). Encrypt/decrypt
71
- // happens locally; DEKs are cacheable per request.
72
- export interface LocalKeyKmsAdapter extends KmsAdapterBase {
73
- readonly capabilities: { readonly mode: "local-key" };
74
-
75
- /**
76
- * Throws KeyErasedError after eraseKey (callers render "[[erased]]"),
77
- * KeyNotFoundError when the subject never had a key (typically a bug).
78
- */
79
- getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
80
- }
81
-
82
- // Backends that never release key material (Vault transit, cloud KMS).
83
- // Every encrypt/decrypt is a round-trip; nothing is cacheable.
84
- export interface RemoteCryptoKmsAdapter extends KmsAdapterBase {
85
- readonly capabilities: { readonly mode: "remote-crypto" };
86
-
87
- encrypt(subject: SubjectId, plaintext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
88
-
89
- /** Same error contract as LocalKeyKmsAdapter.getKey. */
90
- decrypt(subject: SubjectId, ciphertext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
91
- }
92
-
93
- export type KmsAdapter = LocalKeyKmsAdapter | RemoteCryptoKmsAdapter;
94
-
95
- export function isLocalKeyKmsAdapter(adapter: KmsAdapter): adapter is LocalKeyKmsAdapter {
96
- return adapter.capabilities.mode === "local-key";
97
- }
98
-
99
- export class KeyErasedError extends Error {
100
- constructor(public readonly subject: SubjectId) {
101
- super(`Subject key erased: ${subjectIdToKey(subject)}`);
102
- this.name = "KeyErasedError";
103
- }
104
- }
105
-
106
- export class KeyNotFoundError extends Error {
107
- constructor(public readonly subject: SubjectId) {
108
- super(`Subject key not found: ${subjectIdToKey(subject)}`);
109
- this.name = "KeyNotFoundError";
110
- }
111
- }
112
-
113
- export class KeyAlreadyExistsError extends Error {
114
- constructor(public readonly subject: SubjectId) {
115
- super(`Subject key already exists: ${subjectIdToKey(subject)}`);
116
- this.name = "KeyAlreadyExistsError";
117
- }
118
- }
1
+ // Legacy path re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildFilterWhere } from "../event-store-executor-context";
3
+
4
+ describe("buildFilterWhere", () => {
5
+ test("eq: returns a direct field-equality WhereObject", () => {
6
+ expect(buildFilterWhere("status", "eq", "active")).toEqual({ status: "active" });
7
+ });
8
+
9
+ test("ne: wraps the value in a { ne } clause", () => {
10
+ expect(buildFilterWhere("status", "ne", "active")).toEqual({ status: { ne: "active" } });
11
+ });
12
+
13
+ test("lt: wraps the value in a { lt } clause", () => {
14
+ expect(buildFilterWhere("createdAt", "lt", 100)).toEqual({ createdAt: { lt: 100 } });
15
+ });
16
+
17
+ test("gt: wraps the value in a { gt } clause", () => {
18
+ expect(buildFilterWhere("createdAt", "gt", 100)).toEqual({ createdAt: { gt: 100 } });
19
+ });
20
+
21
+ test("in: non-empty array → direct array WhereObject", () => {
22
+ expect(buildFilterWhere("status", "in", ["active", "pending"])).toEqual({
23
+ status: ["active", "pending"],
24
+ });
25
+ });
26
+
27
+ test("in: empty array → null (no-match short-circuit)", () => {
28
+ expect(buildFilterWhere("status", "in", [])).toBeNull();
29
+ });
30
+
31
+ test("in: non-array value → null (no-match short-circuit)", () => {
32
+ expect(buildFilterWhere("status", "in", "not-an-array")).toBeNull();
33
+ });
34
+ });
@@ -9,6 +9,7 @@ import { asRawClient } from "../../db/query";
9
9
  import { createEntity, createTextField } from "../../engine";
10
10
  import { from } from "../../engine/ownership";
11
11
  import { createEventsTable } from "../../event-store";
12
+ import type { EntityCache } from "../../pipeline/entity-cache";
12
13
  import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
13
14
  import { createEventStoreExecutor } from "../event-store-executor";
14
15
  import { buildEntityTable } from "../table-builder";
@@ -303,3 +304,93 @@ async function unsafeCreateEntityTableFor(
303
304
  ): Promise<void> {
304
305
  await unsafeCreateEntityTable(testDb.db, entity, name);
305
306
  }
307
+
308
+ // =============================================================================
309
+ // Concurrent update race → EventStoreVersionConflict catch + entityCache.del
310
+ // on forget/restore (create/update/delete already exercise cache in the
311
+ // main suite; forget/restore del() stayed uncovered).
312
+ // =============================================================================
313
+
314
+ const raceEntity = createEntity({
315
+ table: "read_es_write_race",
316
+ fields: {
317
+ email: createTextField({ required: true }),
318
+ },
319
+ softDelete: true,
320
+ });
321
+ const raceTable = buildEntityTable("esWriteRace", raceEntity);
322
+
323
+ describe("event-store-executor write-verbs — concurrent version race + cache", () => {
324
+ const store = new Map<string, Record<string, unknown>>();
325
+ const entityCache: EntityCache = {
326
+ get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
327
+ mget: async () => new Map(),
328
+ set: async (tenantId, name, id, data) => {
329
+ store.set(`${tenantId}:${name}:${id}`, data);
330
+ },
331
+ mset: async (tenantId, name, entries) => {
332
+ for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
333
+ },
334
+ del: async (tenantId, name, id) => {
335
+ store.delete(`${tenantId}:${name}:${id}`);
336
+ },
337
+ };
338
+ const crud = createEventStoreExecutor(raceTable, raceEntity, {
339
+ entityName: "esWriteRace",
340
+ entityCache,
341
+ });
342
+
343
+ beforeAll(async () => {
344
+ await unsafeCreateEntityTableFor(raceEntity, "esWriteRace");
345
+ });
346
+
347
+ beforeEach(async () => {
348
+ store.clear();
349
+ await asRawClient(testDb.db).unsafe(
350
+ `TRUNCATE kumiko_events, read_es_write_race RESTART IDENTITY CASCADE`,
351
+ );
352
+ });
353
+
354
+ test("two concurrent updates with the same version → one wins, one version_conflict", async () => {
355
+ const created = await crud.create({ email: "race@test.de" }, admin, tdb);
356
+ if (!created.isSuccess) throw new Error("setup failed");
357
+ const id = created.data.id;
358
+
359
+ const [a, b] = await Promise.all([
360
+ crud.update({ id, version: 1, changes: { email: "a@test.de" } }, admin, tdb),
361
+ crud.update({ id, version: 1, changes: { email: "b@test.de" } }, admin, tdb),
362
+ ]);
363
+
364
+ const results = [a, b];
365
+ expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
366
+ expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
367
+ 1,
368
+ );
369
+ });
370
+
371
+ test("forget with entityCache clears the cache entry", async () => {
372
+ const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
373
+ if (!created.isSuccess) throw new Error("setup failed");
374
+ const id = created.data.id;
375
+ const key = `${admin.tenantId}:esWriteRace:${id}`;
376
+ store.set(key, { email: "poison" });
377
+
378
+ const result = await crud.forget({ id }, admin, tdb);
379
+ expect(result.isSuccess).toBe(true);
380
+ expect(store.has(key)).toBe(false);
381
+ });
382
+
383
+ test("restore with entityCache clears the cache entry", async () => {
384
+ const created = await crud.create({ email: "cache-restore@test.de" }, admin, tdb);
385
+ if (!created.isSuccess) throw new Error("setup failed");
386
+ const id = created.data.id;
387
+ await crud.delete({ id }, admin, tdb);
388
+
389
+ const key = `${admin.tenantId}:esWriteRace:${id}`;
390
+ store.set(key, { email: "poison" });
391
+
392
+ const result = await crud.restore({ id }, admin, tdb);
393
+ expect(result.isSuccess).toBe(true);
394
+ expect(store.has(key)).toBe(false);
395
+ });
396
+ });
package/src/db/cursor.ts CHANGED
@@ -1,21 +1,4 @@
1
- import type { EntityId, TenantId } from "../engine/types/identifiers";
2
-
3
- export type CursorQueryOptions = {
4
- tenantId: TenantId;
5
- cursor?: string;
6
- limit?: number;
7
- filterIds?: readonly EntityId[];
8
- sort?: string;
9
- sortDirection?: "asc" | "desc";
10
- };
11
-
12
- export type CursorResult<T> = {
13
- rows: T[];
14
- nextCursor: string | null;
15
- /** Optional total row count — nur present wenn der Caller `totalCount: true`
16
- * in der Query setzt. */
17
- total?: number;
18
- };
1
+ export type { CursorQueryOptions, CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
19
2
 
20
3
  // String-basiert damit UUIDs (Default seit Sprint F) + Integer-Auto-Increment
21
4
  // durch denselben Cursor-Pfad laufen. UUIDv7 erfüllt die lex-Monotonie
package/src/db/dialect.ts CHANGED
@@ -13,6 +13,12 @@
13
13
  // The framework no longer imports drizzle-orm at runtime — schema-files
14
14
  // use only this module.
15
15
 
16
+ import {
17
+ type ColumnHandle,
18
+ KUMIKO_COLUMNS_SYMBOL,
19
+ KUMIKO_NAME_SYMBOL,
20
+ type SchemaTable,
21
+ } from "@cosmicdrift/kumiko-types/schema-table-types";
16
22
  import type {
17
23
  ColumnMeta,
18
24
  CompositePrimaryKeyMeta,
@@ -21,6 +27,8 @@ import type {
21
27
  PgType,
22
28
  } from "./entity-table-meta";
23
29
 
30
+ export type { ColumnHandle, SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
31
+
24
32
  // Public type aliases — historical compat for callers that used to import
25
33
  // these from drizzle-orm/pg-core. SelectQuery is no longer a meaningful
26
34
  // shape (no chain builder); TableColumns is the new SchemaTable union.
@@ -29,31 +37,12 @@ export type TableColumns<_T = any> = SchemaTable;
29
37
  // biome-ignore lint/suspicious/noExplicitAny: legacy type — chain API is gone
30
38
  export type SelectQuery = any;
31
39
 
32
- // Column handle exposed on the SchemaTable. The `name` is the SQL column
33
- // name (snake_case); legacy code accesses `table.fieldName.name` to
34
- // produce raw SQL.
35
- export type ColumnHandle = {
36
- readonly name: string;
37
- readonly pgType: PgType;
38
- readonly getSQLType: () => string;
39
- };
40
-
41
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
42
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
43
40
  // Shadow-proof handle on the EntityTableMeta. The column handles below are
44
41
  // spread as enumerable props, so an entity field named `source`/`columns`/
45
42
  // `tableName`/… would overwrite the matching meta key. extractTableInfo reads
46
43
  // the canonical meta from this symbol instead of the (shadowable) props.
47
44
  const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
48
45
 
49
- // SchemaTable — opaque shape with both EntityTableMeta + Symbol-based
50
- // introspection. Returned by `table(...)`.
51
- export type SchemaTable = EntityTableMeta & {
52
- readonly [KUMIKO_NAME_SYMBOL]: string;
53
- readonly [KUMIKO_COLUMNS_SYMBOL]: Record<string, ColumnHandle>;
54
- readonly [field: string]: unknown;
55
- };
56
-
57
46
  function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
58
47
  return t.startsWith("numeric(");
59
48
  }
@@ -1,92 +1,2 @@
1
- // Plain-data types for EntityTableMeta split from the runtime
2
- // (buildEntityTableMeta, resolveTableName, defineUnmanagedTable) in
3
- // entity-table-meta.ts. Prep step for the types-only package extraction
4
- // (#1283) — this file must have ONLY `import type`, no value imports
5
- // (crypto/DB deps).
6
-
7
- import type { EntityRelations } from "../engine/types";
8
-
9
- // PG type repertoire the read-model tables need. Deliberately narrow — no
10
- // vendor-specific types (TSVECTOR, HSTORE, etc.). An app-author who needs
11
- // those reaches into the reviewed SQL migration by hand, not the generator.
12
- export type PgType =
13
- | "uuid"
14
- | "text"
15
- | "boolean"
16
- | "integer"
17
- | "double precision"
18
- | "bigint"
19
- | "serial"
20
- | "bigserial"
21
- | "jsonb"
22
- | "timestamptz"
23
- | "timestamptz(3)"
24
- // Exact decimal — precision/scale are encoded in the type string so the
25
- // DDL renderer and read-coercion need no side-channel metadata.
26
- | `numeric(${number},${number})`;
27
-
28
- export type ColumnMeta = {
29
- readonly name: string; // snake_case PG column name
30
- readonly pgType: PgType;
31
- readonly notNull: boolean;
32
- // Raw SQL-default-expression (e.g. `now()`, `gen_random_uuid()`,
33
- // `'[]'::jsonb`). undefined = no DEFAULT clause.
34
- readonly defaultSql?: string;
35
- readonly primaryKey?: boolean;
36
- readonly identity?: boolean;
37
- // bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
38
- // drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
39
- readonly bigintJsMode?: "number" | "bigint";
40
- };
41
-
42
- export type IndexMeta = {
43
- readonly name: string;
44
- readonly columns: readonly string[]; // snake_case PG column names
45
- readonly unique?: boolean;
46
- // Raw SQL-where-expression for partial indexes. Caller is responsible
47
- // for safety — emitted verbatim.
48
- readonly whereSql?: string;
49
- // Set when the EntityDefinition has a partial index (def.where as a
50
- // drizzle SQL AST) the generator can't reliably render. The renderer
51
- // emits the statement COMMENTED OUT with a warning hint — the app-author
52
- // has to add the WHERE manually in the generated SQL.
53
- readonly needsManualWhere?: boolean;
54
- };
55
-
56
- export type CompositePrimaryKeyMeta = {
57
- readonly name: string;
58
- readonly columns: readonly string[];
59
- };
60
-
61
- export type EntityTableMeta = {
62
- readonly tableName: string;
63
- readonly columns: readonly ColumnMeta[];
64
- readonly indexes: readonly IndexMeta[];
65
- // For tables with composite PK (no single id column, e.g. snapshots
66
- // keyed by aggregate_id+version). When set, no column should have
67
- // primaryKey:true; the constraint is emitted at table-level.
68
- readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
69
- // Source hint for diagnostics/tests — not used functionally.
70
- // "managed" = from EntityDefinition (with base-columns + audit trail).
71
- // "unmanaged" = via defineUnmanagedTable — no standard audit, the app
72
- // carries the responsibility. Migration-generator + tooling can use the
73
- // discriminator to render warnings ("X tables are unmanaged").
74
- readonly source: "managed" | "unmanaged";
75
- // PII-subject-annotated field names (pii/userOwned/tenantOwned). Set by
76
- // buildEntityTableMeta so the registry can reject r.storeTable stores
77
- // whose direct writes would skip the executor's encryption (#820).
78
- readonly piiSubjectFields?: readonly string[];
79
- };
80
-
81
- export type BuildEntityTableMetaOptions = {
82
- readonly featureName?: string;
83
- readonly relations?: EntityRelations;
84
- readonly source?: "managed" | "unmanaged";
85
- };
86
-
87
- export type UnmanagedTableInput = {
88
- readonly tableName: string;
89
- readonly columns: readonly ColumnMeta[];
90
- readonly indexes?: readonly IndexMeta[];
91
- readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
92
- };
1
+ // Legacy path — re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/entity-table-meta-types";