@cosmicdrift/kumiko-framework 0.48.1 → 0.50.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 +1 -1
- package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
- package/src/api/__tests__/origin-middleware.test.ts +191 -0
- package/src/api/anonymous-cookie.ts +1 -0
- package/src/api/api-constants.ts +9 -0
- package/src/api/auth-middleware.ts +2 -0
- package/src/api/auth-routes.ts +29 -4
- package/src/api/csrf-middleware.ts +1 -4
- package/src/api/origin-middleware.ts +98 -0
- package/src/api/server.ts +20 -0
- package/src/bun-db/__tests__/_helpers.ts +1 -0
- package/src/bun-db/query.ts +1 -0
- package/src/db/__tests__/migrate-generator.test.ts +11 -0
- package/src/db/collect-table-metas.ts +2 -1
- package/src/db/dialect.ts +3 -1
- package/src/db/migrate-generator.ts +6 -2
- package/src/db/queries/event-store.ts +1 -0
- package/src/engine/__tests__/boot-validator.test.ts +23 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
- package/src/engine/__tests__/config-helpers.test.ts +57 -0
- package/src/engine/boot-validator/config-deps.ts +26 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/build-app-schema.ts +45 -0
- package/src/engine/build-config-feature-schema.ts +228 -0
- package/src/engine/config-helpers.ts +25 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +7 -1
- package/src/engine/feature-manifest.ts +1 -7
- package/src/engine/index.ts +8 -0
- package/src/engine/pipeline.ts +1 -0
- package/src/engine/qualified-name.ts +1 -0
- package/src/engine/state-machine.ts +1 -0
- package/src/engine/steps/compute.ts +1 -1
- package/src/engine/steps/return.ts +1 -0
- package/src/engine/types/config.ts +35 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/screen.ts +13 -0
- package/src/env/index.ts +1 -0
- package/src/errors/write-error-info.ts +2 -0
- package/src/es-ops/__tests__/context.integration.test.ts +33 -24
- package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
- package/src/es-ops/context.ts +6 -4
- package/src/event-store/event-store.ts +3 -0
- package/src/files/file-handle.ts +1 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
- package/src/migrations/index.ts +1 -0
- package/src/migrations/pending-rebuilds.ts +48 -9
- package/src/pipeline/dispatcher.ts +5 -5
- package/src/pipeline/lifecycle-pipeline.ts +4 -4
- package/src/stack/table-helpers.ts +1 -0
- package/src/time/tz-context.ts +3 -3
- package/src/utils/compare.ts +10 -0
- package/src/utils/ids.ts +1 -0
- package/src/utils/index.ts +1 -0
|
@@ -162,6 +162,67 @@ describe("pending-rebuilds queue", () => {
|
|
|
162
162
|
|
|
163
163
|
test("no markers, no queue → noop", async () => {
|
|
164
164
|
const run = await runPendingRebuilds(testDb.db, registry);
|
|
165
|
-
expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [] });
|
|
165
|
+
expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// #361: eine managed-Tabelle (Marker tragen nur managed), die in DIESEM Run
|
|
169
|
+
// geleert wurde, aber keine Projektion auflöst = owning-Feature fehlt in der
|
|
170
|
+
// Komposition → laut (unresolvedManaged), aber non-fatal (gedraint, kein Throw).
|
|
171
|
+
test("managed table emptied THIS run with no resolving projection → unresolvedManaged, still drained", async () => {
|
|
172
|
+
writeRebuildMarker(markerDir, "0003_orphan_managed.sql", ["read_orphan_projection"]);
|
|
173
|
+
const queued = await queueRebuildsFromMarkers(testDb.db, {
|
|
174
|
+
migrationsDir: markerDir,
|
|
175
|
+
appliedIds: ["0003_orphan_managed"],
|
|
176
|
+
});
|
|
177
|
+
expect(queued).toEqual(["read_orphan_projection"]);
|
|
178
|
+
|
|
179
|
+
const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: queued });
|
|
180
|
+
expect(run.unresolvedManaged).toEqual(["read_orphan_projection"]);
|
|
181
|
+
expect(run.unmapped).toEqual([]);
|
|
182
|
+
expect(run.rebuilt).toEqual([]);
|
|
183
|
+
expect(run.failed).toEqual([]);
|
|
184
|
+
// Trotz laut: gedraint → kein sticky-stuck Re-Apply.
|
|
185
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// #361: dieselbe unmapped-Tabelle, aber NICHT in thisRunTables (pre-existing
|
|
189
|
+
// pending aus einem früheren Run / evtl. altem unmanaged-Marker) → benign,
|
|
190
|
+
// still gedraint, NICHT als unresolvedManaged geflaggt (kein False-Positive).
|
|
191
|
+
test("pre-existing pending table not in thisRunTables → benign unmapped, not flagged", async () => {
|
|
192
|
+
writeRebuildMarker(markerDir, "0004_legacy.sql", ["read_legacy_table"]);
|
|
193
|
+
await queueRebuildsFromMarkers(testDb.db, {
|
|
194
|
+
migrationsDir: markerDir,
|
|
195
|
+
appliedIds: ["0004_legacy"],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Re-Run ohne frisch gequeuete Tabellen (thisRunTables leer) — die Queue
|
|
199
|
+
// trägt nur den Altbestand.
|
|
200
|
+
const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: [] });
|
|
201
|
+
expect(run.unmapped).toEqual(["read_legacy_table"]);
|
|
202
|
+
expect(run.unresolvedManaged).toEqual([]);
|
|
203
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// #361: ein lauter unresolved-managed-Eintrag darf den auflösbaren Rebuild
|
|
207
|
+
// im selben Run nicht blockieren.
|
|
208
|
+
test("mixed run: resolvable projection rebuilt while an unresolved managed table is flagged", async () => {
|
|
209
|
+
await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
|
|
210
|
+
writeRebuildMarker(markerDir, "0005_mixed.sql", [
|
|
211
|
+
"read_pending_counts",
|
|
212
|
+
"read_orphan_projection",
|
|
213
|
+
]);
|
|
214
|
+
const queued = await queueRebuildsFromMarkers(testDb.db, {
|
|
215
|
+
migrationsDir: markerDir,
|
|
216
|
+
appliedIds: ["0005_mixed"],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: queued });
|
|
220
|
+
expect(run.rebuilt).toEqual([
|
|
221
|
+
{ projection: "pendingtest:projection:pending-counts", eventsProcessed: 1 },
|
|
222
|
+
]);
|
|
223
|
+
expect(run.unresolvedManaged).toEqual(["read_orphan_projection"]);
|
|
224
|
+
expect(run.failed).toEqual([]);
|
|
225
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
226
|
+
expect(await getCount()).toBe(1);
|
|
166
227
|
});
|
|
167
228
|
});
|
package/src/migrations/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { deleteMany, selectMany, upsertOnConflict } from "../db/query";
|
|
|
17
17
|
import { readRebuildMarker } from "../db/rebuild-marker";
|
|
18
18
|
import { tableExists } from "../db/schema-inspection";
|
|
19
19
|
import type { Registry } from "../engine/types";
|
|
20
|
+
import { createFallbackLogger } from "../logging/utils";
|
|
20
21
|
import { rebuildProjection } from "../pipeline";
|
|
21
22
|
import { unsafePushTables } from "../stack";
|
|
22
23
|
import { buildProjectionTableIndex } from "./projection-table-index";
|
|
@@ -78,37 +79,75 @@ export type PendingRebuildRun = {
|
|
|
78
79
|
readonly rebuilt: readonly { readonly projection: string; readonly eventsProcessed: number }[];
|
|
79
80
|
/** Fehlgeschlagene Projektionen — ihre Tabellen BLEIBEN pending. */
|
|
80
81
|
readonly failed: readonly { readonly projection: string; readonly error: string }[];
|
|
81
|
-
/** Pending-Tabellen ohne registrierte Projektion
|
|
82
|
+
/** Pending-Tabellen ohne registrierte Projektion, die NICHT in diesem Run
|
|
83
|
+
* frisch via Marker geleert wurden (pre-existing / Legacy-unmanaged-Marker)
|
|
84
|
+
* — geräumt, still (nicht von echten Legacy-Tabellen unterscheidbar). */
|
|
82
85
|
readonly unmapped: readonly string[];
|
|
86
|
+
/** In DIESEM Run via Marker geleerte managed-Tabellen ohne auflösbare
|
|
87
|
+
* Projektion = das owning-Feature fehlt in der Komposition. Geräumt (kein
|
|
88
|
+
* Stuck-Loop), aber LAUT geloggt — die Projektion ist jetzt leer. */
|
|
89
|
+
readonly unresolvedManaged: readonly string[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type RunPendingRebuildsOptions = {
|
|
93
|
+
/** Tabellen, die in DIESEM apply-Run frisch via Marker gequeued wurden
|
|
94
|
+
* (Rückgabe von `queueRebuildsFromMarkers`). Marker tragen nur managed
|
|
95
|
+
* Tabellen — eine davon ohne auflösbare Projektion ist ein echter Defekt
|
|
96
|
+
* (fehlendes Feature) und wird laut gemeldet statt still geleert. Fehlt die
|
|
97
|
+
* Option, wird jede unmapped Tabelle als pre-existing/benign behandelt
|
|
98
|
+
* (Verhalten vor #361). */
|
|
99
|
+
readonly thisRunTables?: readonly string[];
|
|
83
100
|
};
|
|
84
101
|
|
|
85
102
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
86
103
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
87
104
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
88
|
-
* apply (oder ein direkter Re-Call) holt sie nach.
|
|
105
|
+
* apply (oder ein direkter Re-Call) holt sie nach. Unmapped-Tabellen werden
|
|
106
|
+
* geräumt (kein Stuck-Loop); die in diesem Run frisch geleerten managed-
|
|
107
|
+
* Tabellen ohne Projektion zusätzlich laut gemeldet (`unresolvedManaged`). */
|
|
89
108
|
export async function runPendingRebuilds(
|
|
90
109
|
db: DbConnection,
|
|
91
110
|
registry: Registry,
|
|
111
|
+
options: RunPendingRebuildsOptions = {},
|
|
92
112
|
): Promise<PendingRebuildRun> {
|
|
93
113
|
const pending = await listPendingRebuilds(db);
|
|
94
|
-
if (pending.length === 0)
|
|
114
|
+
if (pending.length === 0) {
|
|
115
|
+
return { rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] };
|
|
116
|
+
}
|
|
95
117
|
|
|
96
118
|
const tableToProjection = buildProjectionTableIndex(registry);
|
|
119
|
+
const thisRun = new Set(options.thisRunTables ?? []);
|
|
97
120
|
const byProjection = new Map<string, string[]>();
|
|
98
121
|
const unmapped: string[] = [];
|
|
122
|
+
const unresolvedManaged: string[] = [];
|
|
99
123
|
for (const tableName of pending) {
|
|
100
124
|
const projection = tableToProjection.get(tableName);
|
|
101
125
|
if (projection === undefined) {
|
|
102
|
-
|
|
126
|
+
// Marker tragen nur managed Tabellen (rebuild-marker.ts). Eine in DIESEM
|
|
127
|
+
// Run frisch geleerte Tabelle ohne auflösbare Projektion ist daher ein
|
|
128
|
+
// echter Defekt (owning-Feature fehlt in der Komposition) → laut. Pre-
|
|
129
|
+
// existing pending Tabellen sind nicht von alten unmanaged-Markern
|
|
130
|
+
// unterscheidbar → still drainen wie bisher (kein Hard-Throw, siehe #361).
|
|
131
|
+
if (thisRun.has(tableName)) unresolvedManaged.push(tableName);
|
|
132
|
+
else unmapped.push(tableName);
|
|
103
133
|
continue;
|
|
104
134
|
}
|
|
105
135
|
byProjection.set(projection, [...(byProjection.get(projection) ?? []), tableName]);
|
|
106
136
|
}
|
|
107
137
|
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
// Beide Klassen räumen: die Queue darf nicht ewig wachsen, und ein Re-Apply
|
|
139
|
+
// darf nicht sticky-stuck werfen. Die Lautstärke liegt im Log + Return-Feld,
|
|
140
|
+
// nicht im Liegenlassen.
|
|
141
|
+
const drained = [...unmapped, ...unresolvedManaged];
|
|
142
|
+
if (drained.length > 0) {
|
|
143
|
+
await clearPendingRebuilds(db, drained);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (unresolvedManaged.length > 0) {
|
|
147
|
+
createFallbackLogger("migrations:pending-rebuilds").error(
|
|
148
|
+
`${unresolvedManaged.length} managed projection table(s) emptied by a migration in this run have no registered projection — the owning feature is likely missing from the composition. They are now EMPTY and were NOT rebuilt: ${unresolvedManaged.join(", ")}. Restore the owning feature or rebuild the projection manually.`,
|
|
149
|
+
{ tables: unresolvedManaged },
|
|
150
|
+
);
|
|
112
151
|
}
|
|
113
152
|
|
|
114
153
|
const rebuilt: { projection: string; eventsProcessed: number }[] = [];
|
|
@@ -122,5 +161,5 @@ export async function runPendingRebuilds(
|
|
|
122
161
|
failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
|
|
123
162
|
}
|
|
124
163
|
}
|
|
125
|
-
return { rebuilt, failed, unmapped };
|
|
164
|
+
return { rebuilt, failed, unmapped, unresolvedManaged };
|
|
126
165
|
}
|
|
@@ -298,9 +298,9 @@ export function createDispatcher(
|
|
|
298
298
|
// (e.g. system-privileged lookups that bypass field-access read filters).
|
|
299
299
|
const bridgeSink = afterCommitHooks ?? [];
|
|
300
300
|
const bridge = {
|
|
301
|
-
query: (targetType: string, payload: unknown) => executeQuery(targetType, payload, user, tx),
|
|
301
|
+
query: (targetType: string, payload: unknown) => executeQuery(targetType, payload, user, tx), // @wrapper-known semantic-alias
|
|
302
302
|
queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
303
|
-
executeQuery(targetType, payload, asUser, tx),
|
|
303
|
+
executeQuery(targetType, payload, asUser, tx), // @wrapper-known semantic-alias
|
|
304
304
|
write: async (targetType: string, payload: unknown) => {
|
|
305
305
|
const res = await executeWrite(targetType, payload, user, tx, bridgeSink);
|
|
306
306
|
return res;
|
|
@@ -476,7 +476,7 @@ export function createDispatcher(
|
|
|
476
476
|
user.tenantId,
|
|
477
477
|
reducer,
|
|
478
478
|
initial,
|
|
479
|
-
{ upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx) },
|
|
479
|
+
{ upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx) }, // @wrapper-known semantic-alias
|
|
480
480
|
);
|
|
481
481
|
},
|
|
482
482
|
queryProjection: async <T = Record<string, unknown>>(
|
|
@@ -521,7 +521,7 @@ export function createDispatcher(
|
|
|
521
521
|
// handler surface just forwards the call so both entry points (login
|
|
522
522
|
// handler via ctx.resolveAuthClaims, switch-tenant route via
|
|
523
523
|
// dispatcher.resolveAuthClaims) cannot drift.
|
|
524
|
-
resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser),
|
|
524
|
+
resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser), // @wrapper-known semantic-alias
|
|
525
525
|
|
|
526
526
|
// Feature-effective check for in-handler opt-in logic. Scope:
|
|
527
527
|
// **current user's tenant** — for cross-tenant lookups (rare,
|
|
@@ -1387,7 +1387,7 @@ export function createDispatcher(
|
|
|
1387
1387
|
return {
|
|
1388
1388
|
db,
|
|
1389
1389
|
queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
|
|
1390
|
-
executeQuery(qn, payload, asUser),
|
|
1390
|
+
executeQuery(qn, payload, asUser), // @wrapper-known semantic-alias
|
|
1391
1391
|
...(configAccessor && { config: configAccessor }),
|
|
1392
1392
|
};
|
|
1393
1393
|
}
|
|
@@ -286,8 +286,8 @@ export function createLifecycleHooks(
|
|
|
286
286
|
payload: result,
|
|
287
287
|
context,
|
|
288
288
|
entityName: result.entityName,
|
|
289
|
-
getHandlerHooks: (n) => registry.getPostSaveHooks(n, phase, eff),
|
|
290
|
-
getEntityHooks: (n) => registry.getEntityPostSaveHooks(n, phase, eff),
|
|
289
|
+
getHandlerHooks: (n) => registry.getPostSaveHooks(n, phase, eff), // @wrapper-known semantic-alias
|
|
290
|
+
getEntityHooks: (n) => registry.getEntityPostSaveHooks(n, phase, eff), // @wrapper-known semantic-alias
|
|
291
291
|
systemHookDefs: systemHooks.postSave,
|
|
292
292
|
phaseLabel: `postSave:${phase}`,
|
|
293
293
|
hookPhase: phase,
|
|
@@ -328,8 +328,8 @@ export function createLifecycleHooks(
|
|
|
328
328
|
payload,
|
|
329
329
|
context,
|
|
330
330
|
entityName: payload.entityName,
|
|
331
|
-
getHandlerHooks: (n) => registry.getPostDeleteHooks(n, phase, eff),
|
|
332
|
-
getEntityHooks: (n) => registry.getEntityPostDeleteHooks(n, phase, eff),
|
|
331
|
+
getHandlerHooks: (n) => registry.getPostDeleteHooks(n, phase, eff), // @wrapper-known semantic-alias
|
|
332
|
+
getEntityHooks: (n) => registry.getEntityPostDeleteHooks(n, phase, eff), // @wrapper-known semantic-alias
|
|
333
333
|
systemHookDefs: systemHooks.postDelete,
|
|
334
334
|
phaseLabel: `postDelete:${phase}`,
|
|
335
335
|
hookPhase: phase,
|
package/src/time/tz-context.ts
CHANGED
|
@@ -74,9 +74,9 @@ export function createTzContext(options: TzContextOptions = {}): TzContext {
|
|
|
74
74
|
return {
|
|
75
75
|
tenant,
|
|
76
76
|
user,
|
|
77
|
-
now: () => T.Now.instant(),
|
|
78
|
-
nowIn: (tz: string) => T.Now.zonedDateTimeISO(tz),
|
|
79
|
-
today: (tz: string) => T.Now.plainDateISO(tz),
|
|
77
|
+
now: () => T.Now.instant(), // @wrapper-known semantic-alias
|
|
78
|
+
nowIn: (tz: string) => T.Now.zonedDateTimeISO(tz), // @wrapper-known semantic-alias
|
|
79
|
+
today: (tz: string) => T.Now.plainDateISO(tz), // @wrapper-known semantic-alias
|
|
80
80
|
todayRange: (tz: string) => {
|
|
81
81
|
const today = T.Now.plainDateISO(tz);
|
|
82
82
|
const startZdt = today.toZonedDateTime({ timeZone: tz });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Locale-independent string ordering for sorts that feed byte-exact serialized
|
|
2
|
+
// artifacts (manifest JSON, snapshot.json, generated migration SQL). Unlike
|
|
3
|
+
// String.localeCompare — whose order depends on the runner's ICU locale and so
|
|
4
|
+
// can drift between a macOS dev box and Linux CI (#330) — this compares by
|
|
5
|
+
// UTF-16 code unit, which is stable across machines and, for the BMP identifier
|
|
6
|
+
// strings we sort here (table / column / feature / qualified names), equals
|
|
7
|
+
// codepoint order.
|
|
8
|
+
export function compareByCodepoint(a: string, b: string): number {
|
|
9
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
10
|
+
}
|
package/src/utils/ids.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { v7 } from "uuid";
|
|
|
11
11
|
// Do NOT use this for security tokens (CSRF, session, API keys). The
|
|
12
12
|
// timestamp prefix leaks creation time and shrinks unpredictable
|
|
13
13
|
// entropy from 122 to 74 bits — use `generateToken` from api/tokens.ts.
|
|
14
|
+
// @wrapper-known semantic-alias
|
|
14
15
|
export function generateId(): string {
|
|
15
16
|
return v7();
|
|
16
17
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { assertUnreachable } from "./assert";
|
|
2
2
|
export { toSnakeCase } from "./case";
|
|
3
|
+
export { compareByCodepoint } from "./compare";
|
|
3
4
|
export { readPositiveIntEnv } from "./env-parse";
|
|
4
5
|
export { generateId } from "./ids";
|
|
5
6
|
export { isPlainObject } from "./is-plain-object";
|