@cosmicdrift/kumiko-framework 0.146.3 → 0.147.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 (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -0,0 +1,276 @@
1
+ import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
2
+ import { executeRawQuery } from "../db/queries/raw-sql";
3
+ import { coerceRow, extractTableInfo } from "../db/query";
4
+ import { buildOwnershipClause, shiftParams } from "../engine/ownership";
5
+ import type { EntityId } from "../engine/types";
6
+ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
7
+ import { getStreamVersion } from "../event-store";
8
+ import { rehydrateCompoundTypes } from "./compound-types";
9
+ import { decodeCursor, encodeCursor } from "./cursor";
10
+ import type { EventStoreExecutor } from "./event-store-executor";
11
+ import { buildFilterWhere, type ExecutorContext } from "./event-store-executor-context";
12
+ import { toSnakeCase } from "./table-builder";
13
+
14
+ // The two read verbs (list/detail) of the event-store-executor. Split out
15
+ // of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
16
+ // relocation, not a redesign: unchanged from the original, now behind an
17
+ // explicit ExecutorContext instead of the factory's local scope.
18
+
19
+ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
20
+ const {
21
+ table,
22
+ entity,
23
+ entityName,
24
+ entityCache,
25
+ searchAdapter,
26
+ softDelete,
27
+ streamTenantFor,
28
+ idFilter,
29
+ loadWithOwnership,
30
+ decryptForRead,
31
+ encryptForStorage,
32
+ } = ctx;
33
+
34
+ return {
35
+ // list + detail are unchanged from crud-executor — projections are the
36
+ // read-model and serve these queries directly.
37
+ async list(payload, user, db, runtimeOptions) {
38
+ const limit = payload.limit ?? 50;
39
+ const offset = payload.offset ?? 0;
40
+ const totalCount = payload.totalCount === true;
41
+
42
+ // H.2 — entity-level read ownership. Decide before touching search or
43
+ // the DB: `empty` means there's no row the user could ever see, so
44
+ // skip both paths and return an empty page.
45
+ const ownership = buildOwnershipClause(user, entity.access?.read, table);
46
+ if (ownership.kind === "empty") {
47
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
48
+ }
49
+
50
+ let filterIds: EntityId[] | undefined;
51
+ // Build-Time options.searchAdapter gewinnt; runtime-Override ist
52
+ // Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
53
+ // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
54
+ // beim Definition-Time noch keinen Server-Context hat).
55
+ const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
56
+ if (payload.search && effectiveSearchAdapter && entityName) {
57
+ const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
58
+ filterType: entityName,
59
+ });
60
+ filterIds = results.map((r) => r.entityId);
61
+ if (filterIds.length === 0) {
62
+ return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
63
+ }
64
+ }
65
+
66
+ // Build the WHERE clause as raw SQL — ownership produces a
67
+ // parameterised fragment that we splice in alongside simple WhereObject
68
+ // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
69
+ const tableName = String(
70
+ (table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
71
+ );
72
+ const whereSql: string[] = [];
73
+ const params: unknown[] = [];
74
+ const colSql = (field: string): string =>
75
+ `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
76
+
77
+ // Tenant-Filter (replicates TenantDb's readWhere semantics).
78
+ if (table["tenantId"] !== undefined && db.mode === "tenant") {
79
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
80
+ whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
81
+ }
82
+ if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
83
+ whereSql.push(`${colSql("isDeleted")} = FALSE`);
84
+ }
85
+ if (payload.cursor) {
86
+ params.push(decodeCursor(payload.cursor));
87
+ whereSql.push(`${colSql("id")} > $${params.length}`);
88
+ }
89
+ if (filterIds) {
90
+ const placeholders = filterIds.map((id) => {
91
+ params.push(id);
92
+ return `$${params.length}`;
93
+ });
94
+ whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
95
+ }
96
+ if (ownership.kind === "sql") {
97
+ const shifted = shiftParams(
98
+ { sqlText: ownership.sqlText, params: ownership.params },
99
+ params.length,
100
+ );
101
+ whereSql.push(shifted.sqlText);
102
+ for (const p of shifted.params) params.push(p);
103
+ }
104
+ const applyFilter = (f: {
105
+ readonly field: string;
106
+ readonly op: "eq" | "ne" | "lt" | "gt" | "in";
107
+ readonly value: unknown;
108
+ }): void => {
109
+ if (table[f.field] === undefined) {
110
+ // skip: unknown field — not a real column, drop the filter (injection guard)
111
+ return;
112
+ }
113
+ const screen = buildFilterWhere(f.field, f.op, f.value);
114
+ if (screen === null) {
115
+ whereSql.push("FALSE");
116
+ // skip: filter is unsatisfiable → emit FALSE, no params to bind
117
+ return;
118
+ }
119
+ for (const [field, value] of Object.entries(screen)) {
120
+ if (Array.isArray(value)) {
121
+ const placeholders = value.map((v) => {
122
+ params.push(v);
123
+ return `$${params.length}`;
124
+ });
125
+ whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
126
+ } else if (typeof value === "object" && value !== null) {
127
+ const opMap: Record<string, string> = {
128
+ gt: ">",
129
+ gte: ">=",
130
+ lt: "<",
131
+ lte: "<=",
132
+ ne: "<>",
133
+ };
134
+ for (const [opKey, opSym] of Object.entries(opMap)) {
135
+ if (!(opKey in value)) continue;
136
+ params.push((value as Record<string, unknown>)[opKey]);
137
+ whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
138
+ }
139
+ } else {
140
+ // Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
141
+ // in bun-db/query.ts — Equality auf lookupable-Feldern matcht
142
+ // Klartext-Arm ODER HMAC-Arm.
143
+ const bidxKey = configuredBlindIndexKey();
144
+ if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
145
+ params.push(value, computeBlindIndex(bidxKey, value));
146
+ whereSql.push(
147
+ `(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
148
+ );
149
+ } else {
150
+ params.push(value);
151
+ whereSql.push(`${colSql(field)} = $${params.length}`);
152
+ }
153
+ }
154
+ }
155
+ };
156
+ if (payload.filter !== undefined) applyFilter(payload.filter);
157
+ if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
158
+
159
+ const orderByClause =
160
+ payload.sort && table[payload.sort]
161
+ ? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}`
162
+ : "";
163
+ const useOffset = !payload.cursor && offset > 0;
164
+ const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
165
+
166
+ const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
167
+ const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
168
+
169
+ const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
170
+ // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
171
+ // Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
172
+ // names, while the encrypted/pii field lists are camelCase — decrypting
173
+ // first silently skipped every multi-word field (ciphertext leaked to
174
+ // the caller).
175
+ const tableInfo = extractTableInfo(table);
176
+ const encryptedRows = rawRows.map((r) =>
177
+ coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
178
+ );
179
+ const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
180
+
181
+ // list rows carry the READ-ROW version (display-only), never an optimistic-lock
182
+ // base — edit flows reload via detail(), which reconciles the stream version.
183
+ // Cache the still-encrypted form: same at-rest guarantee as detail()'s
184
+ // encryptForStorage round-trip, without paying a re-encrypt.
185
+ if (entityCache && entityName && rows.length > 0) {
186
+ await entityCache.mset(
187
+ user.tenantId,
188
+ entityName,
189
+ encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
190
+ );
191
+ }
192
+
193
+ const lastRow = rows[rows.length - 1];
194
+ const nextCursor =
195
+ rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
196
+
197
+ // total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
198
+ // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
199
+ // wie der entsprechende WHERE — bei indexed columns billig genug.
200
+ // Bei Search-Path ist `total = filterIds.length` ohne extra Query.
201
+ let total: number | undefined;
202
+ if (totalCount) {
203
+ if (filterIds) {
204
+ total = filterIds.length;
205
+ } else {
206
+ const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
207
+ const countRows = await executeRawQuery<{ count: number }>(db.raw, countSql, params);
208
+ total = countRows[0]?.count ?? 0;
209
+ }
210
+ }
211
+
212
+ return { rows, nextCursor, ...(total !== undefined && { total }) };
213
+ },
214
+
215
+ async detail(payload, user, db) {
216
+ // H.2 — ownership check. `empty` → the user can never see this row
217
+ // regardless of its id. Return null (same shape as "not found", so a
218
+ // probing attacker can't distinguish "no access" from "doesn't exist").
219
+ const ownership = buildOwnershipClause(user, entity.access?.read, table);
220
+ if (ownership.kind === "empty") return null;
221
+
222
+ const idWhere = idFilter(payload.id);
223
+
224
+ // Stream-version authoritative (same policy as update/Block 0):
225
+ // ctx.appendEvent (lifecycle-writes like incident:post-update) bumps
226
+ // the stream WITHOUT touching row.version — a detail-read that hands
227
+ // out the stale row.version dooms the next CRUD update built on it
228
+ // (entityEdit loads detail.version as its optimistic-lock base) to a
229
+ // guaranteed version_conflict.
230
+ const withStreamVersion = async (
231
+ row: Record<string, unknown>,
232
+ ): Promise<Record<string, unknown>> => {
233
+ const streamVersion = await getStreamVersion(
234
+ db.raw,
235
+ String(payload.id),
236
+ streamTenantFor(user),
237
+ );
238
+ return streamVersion > 0 ? { ...row, version: streamVersion } : row;
239
+ };
240
+
241
+ if (entityCache && entityName) {
242
+ const cached = await entityCache.get(user.tenantId, entityName, payload.id);
243
+ if (cached) {
244
+ if (ownership.kind === "sql") {
245
+ // Re-check ownership predicate against the live row — the cache
246
+ // is keyed only by tenant + id, not by role.
247
+ const checkRows = await loadWithOwnership(db, idWhere, ownership);
248
+ if (checkRows.length === 0) return null;
249
+ }
250
+ // Cached rows are stored re-encrypted (see the `set` below) so an
251
+ // `encrypted` field's plaintext never sits in a second at-rest
252
+ // store (Redis) the field-encryption feature doesn't cover.
253
+ return withStreamVersion(await decryptForRead(cached));
254
+ }
255
+ }
256
+
257
+ const rows = await loadWithOwnership(db, idWhere, ownership);
258
+ const raw = rows[0];
259
+ if (!raw) return null;
260
+ const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
261
+ const rowInfo = extractTableInfo(table);
262
+ const coerced = coerceRow(row, rowInfo);
263
+
264
+ if (entityCache && entityName) {
265
+ await entityCache.set(
266
+ user.tenantId,
267
+ entityName,
268
+ payload.id,
269
+ await encryptForStorage(coerced, user),
270
+ );
271
+ }
272
+
273
+ return withStreamVersion(coerced);
274
+ },
275
+ };
276
+ }