@cosmicdrift/kumiko-framework 0.48.1 → 0.51.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 +2 -2
  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 +43 -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/boot-validator/config-deps.ts +24 -0
  23. package/src/engine/boot-validator/index.ts +11 -4
  24. package/src/engine/build-app-schema.ts +45 -0
  25. package/src/engine/build-config-feature-schema.ts +228 -0
  26. package/src/engine/config-helpers.ts +31 -0
  27. package/src/engine/define-handler.ts +1 -0
  28. package/src/engine/entity-handlers.ts +7 -1
  29. package/src/engine/feature-manifest.ts +6 -7
  30. package/src/engine/index.ts +9 -0
  31. package/src/engine/pipeline.ts +1 -0
  32. package/src/engine/qualified-name.ts +1 -0
  33. package/src/engine/state-machine.ts +1 -0
  34. package/src/engine/steps/compute.ts +1 -1
  35. package/src/engine/steps/return.ts +1 -0
  36. package/src/engine/types/config.ts +65 -1
  37. package/src/engine/types/index.ts +3 -0
  38. package/src/engine/types/screen.ts +13 -0
  39. package/src/env/index.ts +1 -0
  40. package/src/errors/write-error-info.ts +2 -0
  41. package/src/es-ops/__tests__/context.integration.test.ts +33 -24
  42. package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
  43. package/src/es-ops/context.ts +6 -4
  44. package/src/event-store/event-store.ts +3 -0
  45. package/src/files/file-handle.ts +1 -1
  46. package/src/jobs/job-runner.ts +4 -0
  47. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +84 -1
  48. package/src/migrations/index.ts +5 -0
  49. package/src/migrations/pending-rebuilds.ts +84 -10
  50. package/src/pipeline/dispatcher.ts +15 -7
  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
@@ -30,6 +30,7 @@ import {
30
30
  unsafePushTables,
31
31
  } from "../../stack";
32
32
  import {
33
+ enqueueProjectionRebuild,
33
34
  listPendingRebuilds,
34
35
  queueRebuildsFromMarkers,
35
36
  runPendingRebuilds,
@@ -162,6 +163,88 @@ describe("pending-rebuilds queue", () => {
162
163
 
163
164
  test("no markers, no queue → noop", async () => {
164
165
  const run = await runPendingRebuilds(testDb.db, registry);
165
- expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [] });
166
+ expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] });
167
+ });
168
+
169
+ // #361: eine managed-Tabelle (Marker tragen nur managed), die in DIESEM Run
170
+ // geleert wurde, aber keine Projektion auflöst = owning-Feature fehlt in der
171
+ // Komposition → laut (unresolvedManaged), aber non-fatal (gedraint, kein Throw).
172
+ test("managed table emptied THIS run with no resolving projection → unresolvedManaged, still drained", async () => {
173
+ writeRebuildMarker(markerDir, "0003_orphan_managed.sql", ["read_orphan_projection"]);
174
+ const queued = await queueRebuildsFromMarkers(testDb.db, {
175
+ migrationsDir: markerDir,
176
+ appliedIds: ["0003_orphan_managed"],
177
+ });
178
+ expect(queued).toEqual(["read_orphan_projection"]);
179
+
180
+ const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: queued });
181
+ expect(run.unresolvedManaged).toEqual(["read_orphan_projection"]);
182
+ expect(run.unmapped).toEqual([]);
183
+ expect(run.rebuilt).toEqual([]);
184
+ expect(run.failed).toEqual([]);
185
+ // Trotz laut: gedraint → kein sticky-stuck Re-Apply.
186
+ expect(await listPendingRebuilds(testDb.db)).toEqual([]);
187
+ });
188
+
189
+ // #361: dieselbe unmapped-Tabelle, aber NICHT in thisRunTables (pre-existing
190
+ // pending aus einem früheren Run / evtl. altem unmanaged-Marker) → benign,
191
+ // still gedraint, NICHT als unresolvedManaged geflaggt (kein False-Positive).
192
+ test("pre-existing pending table not in thisRunTables → benign unmapped, not flagged", async () => {
193
+ writeRebuildMarker(markerDir, "0004_legacy.sql", ["read_legacy_table"]);
194
+ await queueRebuildsFromMarkers(testDb.db, {
195
+ migrationsDir: markerDir,
196
+ appliedIds: ["0004_legacy"],
197
+ });
198
+
199
+ // Re-Run ohne frisch gequeuete Tabellen (thisRunTables leer) — die Queue
200
+ // trägt nur den Altbestand.
201
+ const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: [] });
202
+ expect(run.unmapped).toEqual(["read_legacy_table"]);
203
+ expect(run.unresolvedManaged).toEqual([]);
204
+ expect(await listPendingRebuilds(testDb.db)).toEqual([]);
205
+ });
206
+
207
+ // #361: ein lauter unresolved-managed-Eintrag darf den auflösbaren Rebuild
208
+ // im selben Run nicht blockieren.
209
+ test("mixed run: resolvable projection rebuilt while an unresolved managed table is flagged", async () => {
210
+ await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
211
+ writeRebuildMarker(markerDir, "0005_mixed.sql", [
212
+ "read_pending_counts",
213
+ "read_orphan_projection",
214
+ ]);
215
+ const queued = await queueRebuildsFromMarkers(testDb.db, {
216
+ migrationsDir: markerDir,
217
+ appliedIds: ["0005_mixed"],
218
+ });
219
+
220
+ const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: queued });
221
+ expect(run.rebuilt).toEqual([
222
+ { projection: "pendingtest:projection:pending-counts", eventsProcessed: 1 },
223
+ ]);
224
+ expect(run.unresolvedManaged).toEqual(["read_orphan_projection"]);
225
+ expect(run.failed).toEqual([]);
226
+ expect(await listPendingRebuilds(testDb.db)).toEqual([]);
227
+ expect(await getCount()).toBe(1);
228
+ });
229
+ });
230
+
231
+ // #362: der framework-Helper. Ohne jobs-Feature (kein jobRunner) rebuildet er
232
+ // synchron inline — das heutige Verhalten, garantiert framework-pur. Der
233
+ // dispatch-Pfad (mit jobs) lebt in jobs/__tests__/projection-rebuild-job.*.
234
+ describe("enqueueProjectionRebuild — inline fallback (no jobs feature)", () => {
235
+ test("without a jobRunner, rebuilds the projection synchronously", async () => {
236
+ await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
237
+ await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
238
+
239
+ const outcome = await enqueueProjectionRebuild("pendingtest:projection:pending-counts", {
240
+ db: testDb.db,
241
+ registry,
242
+ });
243
+
244
+ expect(outcome.mode).toBe("inline");
245
+ if (outcome.mode === "inline") {
246
+ expect(outcome.result.eventsProcessed).toBe(2);
247
+ }
248
+ expect(await getCount()).toBe(2);
166
249
  });
167
250
  });
@@ -10,10 +10,15 @@ export {
10
10
  // Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
11
11
  export {
12
12
  createPendingRebuildsTable,
13
+ type EnqueueProjectionRebuildDeps,
14
+ type EnqueueProjectionRebuildResult,
15
+ enqueueProjectionRebuild,
13
16
  listPendingRebuilds,
14
17
  type PendingRebuildRun,
18
+ PROJECTION_REBUILD_JOB,
15
19
  pendingRebuildsTable,
16
20
  queueRebuildsFromMarkers,
21
+ type RunPendingRebuildsOptions,
17
22
  runPendingRebuilds,
18
23
  } from "./pending-rebuilds";
19
24
  // tableName → projection-name, für den app-seitigen Projection-Rebuild.
@@ -17,7 +17,9 @@ 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 { rebuildProjection } from "../pipeline";
20
+ import type { JobRunner } from "../jobs";
21
+ import { createFallbackLogger } from "../logging/utils";
22
+ import { type RebuildResult, rebuildProjection } from "../pipeline";
21
23
  import { unsafePushTables } from "../stack";
22
24
  import { buildProjectionTableIndex } from "./projection-table-index";
23
25
 
@@ -78,37 +80,75 @@ export type PendingRebuildRun = {
78
80
  readonly rebuilt: readonly { readonly projection: string; readonly eventsProcessed: number }[];
79
81
  /** Fehlgeschlagene Projektionen — ihre Tabellen BLEIBEN pending. */
80
82
  readonly failed: readonly { readonly projection: string; readonly error: string }[];
81
- /** Pending-Tabellen ohne registrierte Projektion geräumt (kein Rebuild-Sinn). */
83
+ /** Pending-Tabellen ohne registrierte Projektion, die NICHT in diesem Run
84
+ * frisch via Marker geleert wurden (pre-existing / Legacy-unmanaged-Marker)
85
+ * — geräumt, still (nicht von echten Legacy-Tabellen unterscheidbar). */
82
86
  readonly unmapped: readonly string[];
87
+ /** In DIESEM Run via Marker geleerte managed-Tabellen ohne auflösbare
88
+ * Projektion = das owning-Feature fehlt in der Komposition. Geräumt (kein
89
+ * Stuck-Loop), aber LAUT geloggt — die Projektion ist jetzt leer. */
90
+ readonly unresolvedManaged: readonly string[];
91
+ };
92
+
93
+ export type RunPendingRebuildsOptions = {
94
+ /** Tabellen, die in DIESEM apply-Run frisch via Marker gequeued wurden
95
+ * (Rückgabe von `queueRebuildsFromMarkers`). Marker tragen nur managed
96
+ * Tabellen — eine davon ohne auflösbare Projektion ist ein echter Defekt
97
+ * (fehlendes Feature) und wird laut gemeldet statt still geleert. Fehlt die
98
+ * Option, wird jede unmapped Tabelle als pre-existing/benign behandelt
99
+ * (Verhalten vor #361). */
100
+ readonly thisRunTables?: readonly string[];
83
101
  };
84
102
 
85
103
  /** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
86
104
  * rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
87
105
  * ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
88
- * apply (oder ein direkter Re-Call) holt sie nach. */
106
+ * apply (oder ein direkter Re-Call) holt sie nach. Unmapped-Tabellen werden
107
+ * geräumt (kein Stuck-Loop); die in diesem Run frisch geleerten managed-
108
+ * Tabellen ohne Projektion zusätzlich laut gemeldet (`unresolvedManaged`). */
89
109
  export async function runPendingRebuilds(
90
110
  db: DbConnection,
91
111
  registry: Registry,
112
+ options: RunPendingRebuildsOptions = {},
92
113
  ): Promise<PendingRebuildRun> {
93
114
  const pending = await listPendingRebuilds(db);
94
- if (pending.length === 0) return { rebuilt: [], failed: [], unmapped: [] };
115
+ if (pending.length === 0) {
116
+ return { rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] };
117
+ }
95
118
 
96
119
  const tableToProjection = buildProjectionTableIndex(registry);
120
+ const thisRun = new Set(options.thisRunTables ?? []);
97
121
  const byProjection = new Map<string, string[]>();
98
122
  const unmapped: string[] = [];
123
+ const unresolvedManaged: string[] = [];
99
124
  for (const tableName of pending) {
100
125
  const projection = tableToProjection.get(tableName);
101
126
  if (projection === undefined) {
102
- unmapped.push(tableName);
127
+ // Marker tragen nur managed Tabellen (rebuild-marker.ts). Eine in DIESEM
128
+ // Run frisch geleerte Tabelle ohne auflösbare Projektion ist daher ein
129
+ // echter Defekt (owning-Feature fehlt in der Komposition) → laut. Pre-
130
+ // existing pending Tabellen sind nicht von alten unmanaged-Markern
131
+ // unterscheidbar → still drainen wie bisher (kein Hard-Throw, siehe #361).
132
+ if (thisRun.has(tableName)) unresolvedManaged.push(tableName);
133
+ else unmapped.push(tableName);
103
134
  continue;
104
135
  }
105
136
  byProjection.set(projection, [...(byProjection.get(projection) ?? []), tableName]);
106
137
  }
107
138
 
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);
139
+ // Beide Klassen räumen: die Queue darf nicht ewig wachsen, und ein Re-Apply
140
+ // darf nicht sticky-stuck werfen. Die Lautstärke liegt im Log + Return-Feld,
141
+ // nicht im Liegenlassen.
142
+ const drained = [...unmapped, ...unresolvedManaged];
143
+ if (drained.length > 0) {
144
+ await clearPendingRebuilds(db, drained);
145
+ }
146
+
147
+ if (unresolvedManaged.length > 0) {
148
+ createFallbackLogger("migrations:pending-rebuilds").error(
149
+ `${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.`,
150
+ { tables: unresolvedManaged },
151
+ );
112
152
  }
113
153
 
114
154
  const rebuilt: { projection: string; eventsProcessed: number }[] = [];
@@ -122,5 +162,39 @@ export async function runPendingRebuilds(
122
162
  failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
123
163
  }
124
164
  }
125
- return { rebuilt, failed, unmapped };
165
+ return { rebuilt, failed, unmapped, unresolvedManaged };
166
+ }
167
+
168
+ // Qualified name of the framework-provided projection-rebuild job. Registered
169
+ // by the `jobs` bundled-feature (createJobsFeature) → available whenever jobs
170
+ // is composed. A jobs-less boot has no jobRunner and rebuilds inline instead.
171
+ export const PROJECTION_REBUILD_JOB = "jobs:job:projection-rebuild";
172
+
173
+ export type EnqueueProjectionRebuildResult =
174
+ | { readonly mode: "dispatched"; readonly bullJobId: string }
175
+ | { readonly mode: "inline"; readonly result: RebuildResult };
176
+
177
+ export type EnqueueProjectionRebuildDeps = {
178
+ readonly db: DbConnection;
179
+ readonly registry: Registry;
180
+ // Present + projection-rebuild job registered (jobs composed) → tracked,
181
+ // retryable job (read_job_runs + read_job_run_logs). Absent → inline rebuild.
182
+ readonly jobRunner?: JobRunner;
183
+ };
184
+
185
+ /** Triggert einen Single-Projection-Rebuild. Mit `jobs`-Feature (jobRunner +
186
+ * registriertem Job) als getrackter, retrybarer Job; ohne jobs synchron inline
187
+ * (heutiges Verhalten). Capability-Detektion über `registry.getJob`, NICHT
188
+ * `hasFeature` — deterministisch und ohne Toggle-Runtime-Abhängigkeit. */
189
+ export async function enqueueProjectionRebuild(
190
+ projection: string,
191
+ deps: EnqueueProjectionRebuildDeps,
192
+ ): Promise<EnqueueProjectionRebuildResult> {
193
+ const { db, registry, jobRunner } = deps;
194
+ if (jobRunner !== undefined && registry.getJob(PROJECTION_REBUILD_JOB) !== undefined) {
195
+ const bullJobId = await jobRunner.dispatch(PROJECTION_REBUILD_JOB, { projection });
196
+ return { mode: "dispatched", bullJobId };
197
+ }
198
+ const result = await rebuildProjection(projection, { db, registry });
199
+ return { mode: "inline", result };
126
200
  }
@@ -278,7 +278,11 @@ export function createDispatcher(
278
278
  // Mirror notify: only built when the config feature wired its factory.
279
279
  const config =
280
280
  context._configAccessorFactory && db
281
- ? context._configAccessorFactory({ user: { id: user.id, tenantId: user.tenantId }, db })
281
+ ? context._configAccessorFactory({
282
+ user: { id: user.id, tenantId: user.tenantId },
283
+ db,
284
+ secrets: context.secrets,
285
+ })
282
286
  : undefined;
283
287
 
284
288
  // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
@@ -298,9 +302,9 @@ export function createDispatcher(
298
302
  // (e.g. system-privileged lookups that bypass field-access read filters).
299
303
  const bridgeSink = afterCommitHooks ?? [];
300
304
  const bridge = {
301
- query: (targetType: string, payload: unknown) => executeQuery(targetType, payload, user, tx),
305
+ query: (targetType: string, payload: unknown) => executeQuery(targetType, payload, user, tx), // @wrapper-known semantic-alias
302
306
  queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
303
- executeQuery(targetType, payload, asUser, tx),
307
+ executeQuery(targetType, payload, asUser, tx), // @wrapper-known semantic-alias
304
308
  write: async (targetType: string, payload: unknown) => {
305
309
  const res = await executeWrite(targetType, payload, user, tx, bridgeSink);
306
310
  return res;
@@ -476,7 +480,7 @@ export function createDispatcher(
476
480
  user.tenantId,
477
481
  reducer,
478
482
  initial,
479
- { upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx) },
483
+ { upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx) }, // @wrapper-known semantic-alias
480
484
  );
481
485
  },
482
486
  queryProjection: async <T = Record<string, unknown>>(
@@ -521,7 +525,7 @@ export function createDispatcher(
521
525
  // handler surface just forwards the call so both entry points (login
522
526
  // handler via ctx.resolveAuthClaims, switch-tenant route via
523
527
  // dispatcher.resolveAuthClaims) cannot drift.
524
- resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser),
528
+ resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser), // @wrapper-known semantic-alias
525
529
 
526
530
  // Feature-effective check for in-handler opt-in logic. Scope:
527
531
  // **current user's tenant** — for cross-tenant lookups (rare,
@@ -1382,12 +1386,16 @@ export function createDispatcher(
1382
1386
  }
1383
1387
  const db = createTenantDb(dbSource, user.tenantId, "tenant", context.tracer, context.meter);
1384
1388
  const configAccessor = context._configAccessorFactory
1385
- ? context._configAccessorFactory({ user: { id: user.id, tenantId: user.tenantId }, db })
1389
+ ? context._configAccessorFactory({
1390
+ user: { id: user.id, tenantId: user.tenantId },
1391
+ db,
1392
+ secrets: context.secrets,
1393
+ })
1386
1394
  : undefined;
1387
1395
  return {
1388
1396
  db,
1389
1397
  queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
1390
- executeQuery(qn, payload, asUser),
1398
+ executeQuery(qn, payload, asUser), // @wrapper-known semantic-alias
1391
1399
  ...(configAccessor && { config: configAccessor }),
1392
1400
  };
1393
1401
  }
@@ -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";