@cosmicdrift/kumiko-framework 0.48.0 → 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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
  3. package/src/api/__tests__/origin-middleware.test.ts +191 -0
  4. package/src/api/anonymous-cookie.ts +1 -0
  5. package/src/api/api-constants.ts +9 -0
  6. package/src/api/auth-middleware.ts +2 -0
  7. package/src/api/auth-routes.ts +29 -4
  8. package/src/api/csrf-middleware.ts +1 -4
  9. package/src/api/origin-middleware.ts +98 -0
  10. package/src/api/server.ts +20 -0
  11. package/src/bun-db/__tests__/_helpers.ts +1 -0
  12. package/src/bun-db/query.ts +1 -0
  13. package/src/db/__tests__/migrate-generator.test.ts +11 -0
  14. package/src/db/collect-table-metas.ts +2 -1
  15. package/src/db/dialect.ts +3 -1
  16. package/src/db/migrate-generator.ts +6 -2
  17. package/src/db/queries/event-store.ts +1 -0
  18. package/src/engine/__tests__/boot-validator.test.ts +23 -0
  19. package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
  20. package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
  21. package/src/engine/__tests__/config-helpers.test.ts +57 -0
  22. package/src/engine/__tests__/feature-manifest.test.ts +45 -0
  23. package/src/engine/boot-validator/config-deps.ts +26 -0
  24. package/src/engine/boot-validator/index.ts +2 -0
  25. package/src/engine/build-app-schema.ts +45 -0
  26. package/src/engine/build-config-feature-schema.ts +228 -0
  27. package/src/engine/config-helpers.ts +25 -0
  28. package/src/engine/define-handler.ts +1 -0
  29. package/src/engine/entity-handlers.ts +7 -1
  30. package/src/engine/feature-manifest.ts +4 -3
  31. package/src/engine/index.ts +8 -0
  32. package/src/engine/pipeline.ts +1 -0
  33. package/src/engine/qualified-name.ts +1 -0
  34. package/src/engine/state-machine.ts +1 -0
  35. package/src/engine/steps/compute.ts +1 -1
  36. package/src/engine/steps/return.ts +1 -0
  37. package/src/engine/types/config.ts +35 -0
  38. package/src/engine/types/index.ts +2 -0
  39. package/src/engine/types/screen.ts +13 -0
  40. package/src/env/index.ts +1 -0
  41. package/src/errors/write-error-info.ts +2 -0
  42. package/src/es-ops/__tests__/context.integration.test.ts +33 -24
  43. package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
  44. package/src/es-ops/context.ts +6 -4
  45. package/src/event-store/event-store.ts +3 -0
  46. package/src/files/file-handle.ts +1 -1
  47. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +62 -1
  48. package/src/migrations/index.ts +1 -0
  49. package/src/migrations/pending-rebuilds.ts +48 -9
  50. package/src/pipeline/dispatcher.ts +5 -5
  51. package/src/pipeline/lifecycle-pipeline.ts +4 -4
  52. package/src/stack/table-helpers.ts +1 -0
  53. package/src/time/tz-context.ts +3 -3
  54. package/src/utils/compare.ts +10 -0
  55. package/src/utils/ids.ts +1 -0
  56. 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
  });
@@ -14,6 +14,7 @@ export {
14
14
  type PendingRebuildRun,
15
15
  pendingRebuildsTable,
16
16
  queueRebuildsFromMarkers,
17
+ type RunPendingRebuildsOptions,
17
18
  runPendingRebuilds,
18
19
  } from "./pending-rebuilds";
19
20
  // tableName → projection-name, für den app-seitigen Projection-Rebuild.
@@ -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 geräumt (kein Rebuild-Sinn). */
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) return { rebuilt: [], failed: [], unmapped: [] };
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
- unmapped.push(tableName);
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
- // Tabellen ohne Projektion: gleiche Semantik wie der bisherige Skip beim
109
- // Apply aber explizit geräumt, damit die Queue nicht ewig wächst.
110
- if (unmapped.length > 0) {
111
- await clearPendingRebuilds(db, unmapped);
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,
@@ -129,6 +129,7 @@ export async function unsafePushTables(
129
129
  }
130
130
  }
131
131
 
132
+ // @wrapper-known semantic-alias
132
133
  function renderColumnType(col: ColumnMeta): string {
133
134
  return pgTypeToSqlType(col.pgType);
134
135
  }
@@ -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
  }
@@ -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";