@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.
- package/package.json +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- 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
|
+
}
|