@cosmicdrift/kumiko-framework 0.159.1 → 0.160.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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/routes.ts +57 -0
  11. package/src/bun-db/query.ts +12 -25
  12. package/src/crypto/kms-adapter.ts +2 -118
  13. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  14. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  15. package/src/db/cursor.ts +1 -18
  16. package/src/db/dialect.ts +8 -19
  17. package/src/db/entity-table-meta-types.ts +2 -92
  18. package/src/db/event-store-executor.ts +4 -96
  19. package/src/db/table-builder.ts +2 -19
  20. package/src/db/tenant-db.ts +6 -55
  21. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  22. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  23. package/src/engine/__tests__/engine.test.ts +28 -0
  24. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  25. package/src/engine/__tests__/registry.test.ts +40 -0
  26. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  27. package/src/engine/boot-validator/entity-handler.ts +10 -1
  28. package/src/engine/define-feature.ts +1 -0
  29. package/src/engine/define-handler.ts +1 -0
  30. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  31. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  32. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  33. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  34. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  35. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  36. package/src/engine/feature-ast/extractors/index.ts +1 -0
  37. package/src/engine/feature-ast/index.ts +2 -0
  38. package/src/engine/feature-ast/parse.ts +3 -0
  39. package/src/engine/feature-ast/patch.ts +2 -0
  40. package/src/engine/feature-ast/patcher.ts +21 -0
  41. package/src/engine/feature-ast/patterns.ts +16 -0
  42. package/src/engine/feature-ast/render.ts +15 -0
  43. package/src/engine/feature-builder-state.ts +3 -0
  44. package/src/engine/feature-entity-handlers.ts +35 -1
  45. package/src/engine/index.ts +3 -0
  46. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  47. package/src/engine/pattern-library/library.ts +2 -0
  48. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  49. package/src/engine/registry-facade.ts +9 -0
  50. package/src/engine/registry-ingest.ts +10 -0
  51. package/src/engine/registry-state.ts +3 -0
  52. package/src/engine/types/config.ts +2 -497
  53. package/src/engine/types/define-handler.ts +2 -94
  54. package/src/engine/types/entity-handlers.ts +2 -30
  55. package/src/engine/types/feature.ts +2 -1021
  56. package/src/engine/types/fields.ts +2 -685
  57. package/src/engine/types/handlers.ts +2 -820
  58. package/src/engine/types/hooks.ts +2 -170
  59. package/src/engine/types/index.ts +44 -36
  60. package/src/engine/types/nav.ts +2 -67
  61. package/src/engine/types/ownership.ts +2 -83
  62. package/src/engine/types/projection.ts +2 -165
  63. package/src/engine/types/screen.ts +2 -747
  64. package/src/engine/types/step.ts +2 -334
  65. package/src/engine/types/workspace.ts +2 -42
  66. package/src/errors/write-error-info.ts +6 -22
  67. package/src/event-store/errors.ts +2 -35
  68. package/src/event-store/event-store.ts +2 -21
  69. package/src/event-store/snapshot.ts +11 -35
  70. package/src/event-store/types.ts +2 -22
  71. package/src/files/provider-resolver.ts +3 -5
  72. package/src/files/types.ts +5 -54
  73. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  74. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  75. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  76. package/src/pipeline/dispatch-shared.ts +39 -1
  77. package/src/pipeline/dispatch-stream.ts +74 -0
  78. package/src/pipeline/dispatcher-utils.ts +1 -1
  79. package/src/pipeline/dispatcher.ts +7 -0
  80. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  81. package/src/rate-limit/resolver.ts +10 -30
  82. package/src/secrets/envelope-cipher.ts +4 -6
  83. package/src/secrets/types.ts +2 -177
  84. package/src/time/tz-context.ts +9 -56
@@ -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";
@@ -1,19 +1,14 @@
1
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
1
2
  import type { LocalKeyKmsAdapter } from "../crypto";
2
- import type {
3
- DeleteContext,
4
- EntityDefinition,
5
- EntityId,
6
- SaveContext,
7
- SessionUser,
8
- WriteResult,
9
- } from "../engine/types";
3
+ import type { EntityDefinition } from "../engine/types";
10
4
  import type { EntityCache } from "../pipeline/entity-cache";
11
5
  import type { SearchAdapter } from "../search/types";
12
6
  import type { EnvelopeCipher } from "../secrets/envelope-cipher";
13
7
  import { buildExecutorContext, type Table } from "./event-store-executor-context";
14
8
  import { createReadVerbs } from "./event-store-executor-read";
15
9
  import { createWriteVerbs } from "./event-store-executor-write";
16
- import type { CursorResult } from "./index";
10
+
11
+ export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
17
12
 
18
13
  // The executor writes events + auto-projection (entity table) in one TX.
19
14
  // It no longer knows about user projections — those are driven by the
@@ -42,93 +37,6 @@ export type EventStoreExecutorOptions = {
42
37
  kms?: LocalKeyKmsAdapter;
43
38
  };
44
39
 
45
- export type EventStoreExecutor = {
46
- create: (
47
- payload: Record<string, unknown>,
48
- user: SessionUser,
49
- db: import("./tenant-db").TenantDb,
50
- ) => Promise<WriteResult<SaveContext>>;
51
-
52
- update: (
53
- payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
54
- user: SessionUser,
55
- db: import("./tenant-db").TenantDb,
56
- options?: { skipOptimisticLock?: boolean; skipUnchanged?: boolean },
57
- ) => Promise<WriteResult<SaveContext>>;
58
-
59
- delete: (
60
- payload: { id: EntityId },
61
- user: SessionUser,
62
- db: import("./tenant-db").TenantDb,
63
- ) => Promise<WriteResult<DeleteContext>>;
64
-
65
- // Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
66
- // which hard-deletes the row even for softDelete entities — and, being an
67
- // auto-verb replayed by the implicit projection, the erasure survives a
68
- // rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
69
- forget: (
70
- payload: { id: EntityId },
71
- user: SessionUser,
72
- db: import("./tenant-db").TenantDb,
73
- ) => Promise<WriteResult<DeleteContext>>;
74
-
75
- restore: (
76
- payload: { id: EntityId },
77
- user: SessionUser,
78
- db: import("./tenant-db").TenantDb,
79
- ) => Promise<WriteResult<SaveContext>>;
80
-
81
- list: (
82
- payload: {
83
- cursor?: string | undefined;
84
- limit?: number | undefined;
85
- search?: string | undefined;
86
- sort?: string | undefined;
87
- sortDirection?: "asc" | "desc" | undefined;
88
- offset?: number | undefined;
89
- totalCount?: boolean | undefined;
90
- filter?:
91
- | {
92
- readonly field: string;
93
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
94
- readonly value: unknown;
95
- }
96
- | undefined;
97
- // User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
98
- // `filter`). Alle werden mit AND verknüpft.
99
- filters?:
100
- | ReadonlyArray<{
101
- readonly field: string;
102
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
103
- readonly value: unknown;
104
- }>
105
- | undefined;
106
- },
107
- user: SessionUser,
108
- db: import("./tenant-db").TenantDb,
109
- /** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
110
- * Executor beim Build keinen SearchAdapter via Options bekommen
111
- * hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
112
- * hier zur Runtime einen aus ctx.searchAdapter durchreichen.
113
- * options.searchAdapter (build-time) gewinnt — runtime-Override
114
- * ist Fallback für die default-Wrapper. */
115
- runtimeOptions?: {
116
- readonly searchAdapter?: SearchAdapter;
117
- // Trash query: skip the implicit `isDeleted = FALSE` filter so soft-
118
- // deleted rows are returned too. Tenant + ownership clauses still apply
119
- // — includeDeleted only relaxes the soft-delete predicate, never the
120
- // visibility ones, so it can ride untrusted query input safely.
121
- readonly includeDeleted?: boolean;
122
- },
123
- ) => Promise<CursorResult<Record<string, unknown>>>;
124
-
125
- detail: (
126
- payload: { id: EntityId },
127
- user: SessionUser,
128
- db: import("./tenant-db").TenantDb,
129
- ) => Promise<Record<string, unknown> | null>;
130
- };
131
-
132
40
  export function createEventStoreExecutor(
133
41
  table: Table,
134
42
  entity: EntityDefinition,
@@ -1,3 +1,4 @@
1
+ import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
1
2
  import type {
2
3
  EntityDefinition,
3
4
  EntityRelations,
@@ -382,25 +383,7 @@ type SoftDeleteColumnsType = {
382
383
  };
383
384
 
384
385
  // ── ES-write brand ──────────────────────────────────────────────────────
385
- // A managed projection is only writable through the executor (event →
386
- // rebuild-safe). To make a direct write a *compile* error rather than a
387
- // convention, EntityTable carries a phantom `unique symbol` prop; the public
388
- // write helpers reject anything that has it (see NotExecutorOnly + query.ts).
389
- // The symbol key dodges SchemaTable's `[field: string]: unknown` index
390
- // signature (string index sigs don't cover symbol keys), so the brand
391
- // survives the type-erasure that would swallow a plain marker prop — and the
392
- // executor seam (applyEntityEvent) erases `table` to TableColumns<any>, which
393
- // carries no such prop, so the one legitimate writer stays green.
394
- declare const EXECUTOR_ONLY: unique symbol;
395
- export interface ExecutorOnly {
396
- readonly [EXECUTOR_ONLY]: true;
397
- }
398
- // Negative brand for write-helper params: a branded EntityTable is NOT
399
- // assignable (its `true` clashes with `never`), while unmanaged EntityTableMeta
400
- // and erased SchemaTable pass (they lack the prop, so `?: never` is satisfied).
401
- export type NotExecutorOnly = {
402
- readonly [EXECUTOR_ONLY]?: never;
403
- };
386
+ export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
404
387
 
405
388
  export type EntityTable<E extends EntityDefinition = EntityDefinition> =
406
389
  TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.