@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.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/api-constants.ts +1 -0
- package/src/api/routes.ts +57 -0
- package/src/bun-db/query.ts +12 -25
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/entity-table-meta-types.ts +2 -92
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +35 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -94
- package/src/engine/types/entity-handlers.ts +2 -30
- package/src/engine/types/feature.ts +2 -1021
- package/src/engine/types/fields.ts +2 -685
- package/src/engine/types/handlers.ts +2 -820
- package/src/engine/types/hooks.ts +2 -170
- package/src/engine/types/index.ts +44 -36
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -83
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/screen.ts +2 -747
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/workspace.ts +2 -42
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +2 -21
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -22
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +39 -1
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/rate-limit/resolver.ts +10 -30
- package/src/secrets/envelope-cipher.ts +4 -6
- package/src/secrets/types.ts +2 -177
- package/src/time/tz-context.ts +9 -56
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
|
|
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
|
-
|
|
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,
|
package/src/db/table-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|