@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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 +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/jwt.test.ts +27 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +5 -5
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/extensions/user-data.ts +6 -0
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
- package/src/files/file-routes.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +55 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- package/src/utils/serialization.ts +10 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
2
|
import type { DbConnection, DbRow, DbTx } from "../db/connection";
|
|
3
3
|
import { selectRowForUpdateById } from "../db/queries/entity-read";
|
|
4
|
-
import { selectMany, transaction } from "../db/query";
|
|
4
|
+
import { asEntityTableMeta, selectMany, transaction } from "../db/query";
|
|
5
5
|
import { buildEntityTable, toSnakeCase } from "../db/table-builder";
|
|
6
6
|
import { createTenantDb } from "../db/tenant-db";
|
|
7
7
|
import { hasAccess } from "../engine/access";
|
|
@@ -717,12 +717,8 @@ export function createDispatcher(
|
|
|
717
717
|
user,
|
|
718
718
|
ip: reqCtx?.ip,
|
|
719
719
|
});
|
|
720
|
-
// skip: ip-
|
|
721
|
-
//
|
|
722
|
-
// through BEFORE requiring a resolver, so system/seed writes through
|
|
723
|
-
// such a handler don't need a RateLimitResolver wired (the es-ops
|
|
724
|
-
// seed dispatcher has none). L1/L2 middleware handle the HTTP-side
|
|
725
|
-
// ip caps.
|
|
720
|
+
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
721
|
+
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
726
722
|
if (bucket.kind === "skip") return;
|
|
727
723
|
if (!context.rateLimit) {
|
|
728
724
|
throw new InternalError({
|
|
@@ -1162,9 +1158,7 @@ export function createDispatcher(
|
|
|
1162
1158
|
// can false-pass; optimistic locking would catch it later, but with
|
|
1163
1159
|
// a less specific error. Falls back to a plain SELECT if no tx is
|
|
1164
1160
|
// active (tests without a DB connection).
|
|
1165
|
-
const tableName =
|
|
1166
|
-
(table as { [key: symbol]: unknown })[Symbol.for("kumiko:schema:Name")],
|
|
1167
|
-
);
|
|
1161
|
+
const tableName = asEntityTableMeta(table)?.tableName ?? "";
|
|
1168
1162
|
const rows = tx
|
|
1169
1163
|
? await selectRowForUpdateById(handlerContext.db, tableName, id)
|
|
1170
1164
|
: await selectMany(handlerContext.db, table, { id });
|
|
@@ -11,11 +11,16 @@ import {
|
|
|
11
11
|
rebuildMetaOrThrow,
|
|
12
12
|
swapShadowIntoLive,
|
|
13
13
|
} from "../db/queries/shadow-swap";
|
|
14
|
-
import { selectMany } from "../db/query";
|
|
14
|
+
import { runInSavepoint, selectMany } from "../db/query";
|
|
15
15
|
import type { Registry, TenantId } from "../engine/types";
|
|
16
16
|
import { InternalError } from "../errors";
|
|
17
17
|
import { eventsTable, type StoredEvent, upcastStoredEvent } from "../event-store";
|
|
18
18
|
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
|
|
19
|
+
import {
|
|
20
|
+
createRebuildDeadLetterTable,
|
|
21
|
+
recordRebuildDeadLetters,
|
|
22
|
+
type SkippedApply,
|
|
23
|
+
} from "../event-store/rebuild-dead-letter";
|
|
19
24
|
import { upcastStoredEvents } from "../event-store/upcaster";
|
|
20
25
|
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
21
26
|
import type { Meter } from "../observability/types/metric";
|
|
@@ -123,12 +128,21 @@ export async function rebuildMultiStreamProjection(
|
|
|
123
128
|
|
|
124
129
|
const meta = rebuildMetaOrThrow(msp.table, mspName);
|
|
125
130
|
|
|
131
|
+
// Declared per-projection (MspErrorMode): the rebuild policy falls back to
|
|
132
|
+
// continuous when omitted — previously declared but never honored here.
|
|
133
|
+
const skipApplyErrors =
|
|
134
|
+
(msp.errorMode?.rebuild ?? msp.errorMode?.continuous)?.skipApplyErrors === true;
|
|
135
|
+
|
|
126
136
|
const startedAt = Date.now();
|
|
127
137
|
let eventsProcessed = 0;
|
|
128
138
|
let lastProcessedEventId = 0n;
|
|
139
|
+
const skipped: SkippedApply[] = [];
|
|
129
140
|
|
|
130
141
|
try {
|
|
131
142
|
await ensureRebuildSchema(db);
|
|
143
|
+
// Outside the rebuild tx, like the schema: idempotent DDL colliding
|
|
144
|
+
// inside the tx would roll the whole replay back.
|
|
145
|
+
if (skipApplyErrors) await createRebuildDeadLetterTable(db);
|
|
132
146
|
await db.begin(async (tx: DbTx) => {
|
|
133
147
|
await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
134
148
|
await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
@@ -178,13 +192,31 @@ export async function rebuildMultiStreamProjection(
|
|
|
178
192
|
});
|
|
179
193
|
const applyFn = msp.apply[row.type];
|
|
180
194
|
if (!applyFn) continue;
|
|
181
|
-
|
|
182
|
-
|
|
195
|
+
if (skipApplyErrors) {
|
|
196
|
+
// Driver-native savepoint: a throwing SQL statement would
|
|
197
|
+
// otherwise poison the rebuild tx (25P02) AND make the driver
|
|
198
|
+
// reject the whole begin() even after a caught error. Apply +
|
|
199
|
+
// ctx run on the savepoint-scoped handle.
|
|
200
|
+
try {
|
|
201
|
+
await runInSavepoint(tx, async (sp) => {
|
|
202
|
+
const rebuildCtx = createRebuildCtx(registry, sp as DbRunner, row.tenantId);
|
|
203
|
+
await applyFn(storedEvent, sp as DbRunner, rebuildCtx);
|
|
204
|
+
});
|
|
205
|
+
} catch (e) {
|
|
206
|
+
skipped.push({ event: storedEvent, error: e });
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
|
|
210
|
+
await applyFn(storedEvent, tx, rebuildCtx);
|
|
211
|
+
}
|
|
183
212
|
eventsProcessed++;
|
|
184
213
|
lastProcessedEventId = row.id;
|
|
185
214
|
}
|
|
186
215
|
}
|
|
187
216
|
|
|
217
|
+
if (skipped.length > 0) {
|
|
218
|
+
await recordRebuildDeadLetters(tx, mspName, skipped);
|
|
219
|
+
}
|
|
188
220
|
await updateConsumerRebuildCursor(
|
|
189
221
|
tx,
|
|
190
222
|
mspName,
|
|
@@ -210,6 +242,7 @@ export async function rebuildMultiStreamProjection(
|
|
|
210
242
|
const result: RebuildResult = {
|
|
211
243
|
projection: mspName,
|
|
212
244
|
eventsProcessed,
|
|
245
|
+
eventsSkipped: skipped.length,
|
|
213
246
|
lastProcessedEventId,
|
|
214
247
|
durationMs: Date.now() - startedAt,
|
|
215
248
|
};
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
rebuildMetaOrThrow,
|
|
14
14
|
swapShadowIntoLive,
|
|
15
15
|
} from "../db/queries/shadow-swap";
|
|
16
|
-
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
16
|
+
import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query";
|
|
17
17
|
import type { Registry, TenantId } from "../engine/types";
|
|
18
18
|
import {
|
|
19
19
|
eventsTable,
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
upcastStoredEvent,
|
|
23
23
|
} from "../event-store";
|
|
24
24
|
import type { EventMetadata } from "../event-store/event-store";
|
|
25
|
+
import {
|
|
26
|
+
createRebuildDeadLetterTable,
|
|
27
|
+
recordRebuildDeadLetters,
|
|
28
|
+
type SkippedApply,
|
|
29
|
+
} from "../event-store/rebuild-dead-letter";
|
|
25
30
|
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
26
31
|
import type { Meter } from "../observability/types/metric";
|
|
27
32
|
import { projectionStateTable } from "./projection-state";
|
|
@@ -119,7 +124,12 @@ function rowToStoredEvent(row: StoredEventRow): StoredEvent {
|
|
|
119
124
|
|
|
120
125
|
export type RebuildResult = {
|
|
121
126
|
readonly projection: string;
|
|
127
|
+
// Events consumed from the log — includes quarantined ones (the cursor
|
|
128
|
+
// advanced past them).
|
|
122
129
|
readonly eventsProcessed: number;
|
|
130
|
+
// Poison events quarantined into kumiko_rebuild_dead_letters this run.
|
|
131
|
+
// Always 0 unless quarantine mode was active (#760).
|
|
132
|
+
readonly eventsSkipped: number;
|
|
123
133
|
readonly lastProcessedEventId: bigint;
|
|
124
134
|
readonly durationMs: number;
|
|
125
135
|
};
|
|
@@ -145,12 +155,25 @@ type RebuildDeps = {
|
|
|
145
155
|
// on the live table; if a long-running writer holds it past this, the rebuild
|
|
146
156
|
// fails loud instead of hanging. Defaults to DEFAULT_FENCE_LOCK_TIMEOUT_MS.
|
|
147
157
|
readonly fenceLockTimeoutMs?: number;
|
|
158
|
+
// Apply-error policy for THIS run. Default strict: the first throwing
|
|
159
|
+
// apply aborts the rebuild (tx rollback, status "failed") — a poison
|
|
160
|
+
// event makes the projection permanently un-rebuildable (#760).
|
|
161
|
+
// skipApplyErrors: true confines every apply to a savepoint; a throwing
|
|
162
|
+
// apply is rolled back, recorded into kumiko_rebuild_dead_letters and
|
|
163
|
+
// skipped, and the rebuild completes without its effect. Opt-in for
|
|
164
|
+
// operators unblocking a pinned rebuild — the projection then knowingly
|
|
165
|
+
// misses the quarantined events until they are repaired and replayed.
|
|
166
|
+
readonly errorPolicy?: { readonly skipApplyErrors?: boolean };
|
|
148
167
|
// Test-only seam: fires once after the unlocked bulk drain and before the
|
|
149
168
|
// cutover fence. Lets a concurrency test inject a committed write into the
|
|
150
169
|
// replay window deterministically. The `__test_` prefix marks it test-only:
|
|
151
170
|
// a slow callback here delays the cutover fence acquisition, widening the
|
|
152
171
|
// window in which concurrent writers can still commit below the cursor.
|
|
153
172
|
readonly __test_onBeforeFence?: () => void | Promise<void>;
|
|
173
|
+
// Test-only seam: fires each time the shadow table is (re)built. The #443
|
|
174
|
+
// recompute is idempotent on final values, so only a call-count seam like
|
|
175
|
+
// this can prove it didn't fire on a clean, non-concurrent rebuild.
|
|
176
|
+
readonly __test_onBuildShadowTable?: () => void;
|
|
154
177
|
};
|
|
155
178
|
|
|
156
179
|
export async function rebuildProjection(
|
|
@@ -170,7 +193,7 @@ export async function rebuildProjection(
|
|
|
170
193
|
const meta = rebuildMetaOrThrow(projection.table, projectionName);
|
|
171
194
|
|
|
172
195
|
const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
|
|
173
|
-
const sourcesList = [...sources];
|
|
196
|
+
const sourcesList = [...sources, ...(projection.extraSources ?? [])];
|
|
174
197
|
const subscribedList = Object.keys(projection.apply);
|
|
175
198
|
// Upcasters run at read time: older stored payloads get walked through the
|
|
176
199
|
// registered r.eventMigration chain until their shape matches the current
|
|
@@ -181,6 +204,11 @@ export async function rebuildProjection(
|
|
|
181
204
|
const startedAt = Date.now();
|
|
182
205
|
let eventsProcessed = 0;
|
|
183
206
|
let lastProcessedEventId = 0n;
|
|
207
|
+
const skipApplyErrors = deps.errorPolicy?.skipApplyErrors === true;
|
|
208
|
+
// Quarantined events, collected in memory and written once after the
|
|
209
|
+
// replay settles — the #443 full-re-replay path would otherwise duplicate
|
|
210
|
+
// the dead-letter rows.
|
|
211
|
+
let skipped: SkippedApply[] = [];
|
|
184
212
|
|
|
185
213
|
// One chronological batch of events after lastProcessedEventId, applied into
|
|
186
214
|
// the shadow. Returns the batch size so the caller can detect the tail
|
|
@@ -204,7 +232,21 @@ export async function rebuildProjection(
|
|
|
204
232
|
// skip: apply-key validation ensures every subscribed type has a handler;
|
|
205
233
|
// defensive check against runtime-mutated registry
|
|
206
234
|
if (!applyFn) continue;
|
|
207
|
-
|
|
235
|
+
if (skipApplyErrors) {
|
|
236
|
+
// Driver-native savepoint: a throwing SQL statement would otherwise
|
|
237
|
+
// poison the rebuild tx (25P02) AND make the driver reject the whole
|
|
238
|
+
// begin() even after a caught error. The apply runs on the
|
|
239
|
+
// savepoint-scoped handle so its statements are confined.
|
|
240
|
+
try {
|
|
241
|
+
await runInSavepoint(tx, async (sp) => {
|
|
242
|
+
await applyFn(storedEvent, sp as DbTx, projection.table);
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
skipped.push({ event: storedEvent, error: e });
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
await applyFn(storedEvent, tx, projection.table);
|
|
249
|
+
}
|
|
208
250
|
eventsProcessed++;
|
|
209
251
|
lastProcessedEventId = row.id;
|
|
210
252
|
}
|
|
@@ -213,9 +255,13 @@ export async function rebuildProjection(
|
|
|
213
255
|
|
|
214
256
|
try {
|
|
215
257
|
await ensureRebuildSchema(db);
|
|
258
|
+
// Outside the rebuild tx, like the schema: idempotent DDL colliding
|
|
259
|
+
// inside the tx would roll the whole replay back.
|
|
260
|
+
if (skipApplyErrors) await createRebuildDeadLetterTable(db);
|
|
216
261
|
await db.begin(async (tx: DbTx) => {
|
|
217
262
|
await markProjectionRebuilding(tx, projectionName);
|
|
218
263
|
await buildShadowTable(tx, meta);
|
|
264
|
+
deps.__test_onBuildShadowTable?.();
|
|
219
265
|
|
|
220
266
|
// A projection that subscribes to nothing has no events to replay and no
|
|
221
267
|
// live writer touching its table — skip straight to swapping the empty
|
|
@@ -253,14 +299,19 @@ export async function rebuildProjection(
|
|
|
253
299
|
const expectedEvents = await countSubscribedEvents(tx, sourcesList, subscribedList);
|
|
254
300
|
if (expectedEvents > BigInt(eventsProcessed)) {
|
|
255
301
|
await buildShadowTable(tx, meta);
|
|
302
|
+
deps.__test_onBuildShadowTable?.();
|
|
256
303
|
lastProcessedEventId = 0n;
|
|
257
304
|
eventsProcessed = 0;
|
|
305
|
+
skipped = [];
|
|
258
306
|
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
259
307
|
// re-replay the full log into the fresh shadow
|
|
260
308
|
}
|
|
261
309
|
}
|
|
262
310
|
}
|
|
263
311
|
|
|
312
|
+
if (skipped.length > 0) {
|
|
313
|
+
await recordRebuildDeadLetters(tx, projectionName, skipped);
|
|
314
|
+
}
|
|
264
315
|
await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
|
|
265
316
|
await swapShadowIntoLive(tx, meta.tableName);
|
|
266
317
|
});
|
|
@@ -286,6 +337,7 @@ export async function rebuildProjection(
|
|
|
286
337
|
const result: RebuildResult = {
|
|
287
338
|
projection: projectionName,
|
|
288
339
|
eventsProcessed,
|
|
340
|
+
eventsSkipped: skipped.length,
|
|
289
341
|
lastProcessedEventId,
|
|
290
342
|
durationMs: Date.now() - startedAt,
|
|
291
343
|
};
|
package/src/schema-cli.ts
CHANGED
|
@@ -44,17 +44,28 @@ export type SchemaCliOut = {
|
|
|
44
44
|
|
|
45
45
|
const SNAPSHOT_FILENAME = ".snapshot.json";
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
type SchemaMod = {
|
|
48
|
+
readonly entityMetas: Parameters<typeof renderTablesDdl>[0];
|
|
49
|
+
readonly features?: unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Single load-path for kumiko/schema.ts (512/3) — `generate` and `validate`
|
|
53
|
+
// both need ENTITY_METAS (and validate additionally wants FEATURES); this is
|
|
54
|
+
// the one place that enforces "ENTITY_METAS must be an array" so the two
|
|
55
|
+
// commands can't drift on the check or its wording. Throws — callers that
|
|
56
|
+
// want a graceful exit code (validate) catch it themselves.
|
|
57
|
+
async function loadSchemaModFromApp(schemaFile: string): Promise<SchemaMod> {
|
|
50
58
|
// bun imports TS directly — no spawn needed.
|
|
51
|
-
const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown };
|
|
59
|
+
const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown; FEATURES?: unknown };
|
|
52
60
|
if (!Array.isArray(mod.ENTITY_METAS)) {
|
|
53
61
|
throw new Error(
|
|
54
62
|
`Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
|
|
55
63
|
);
|
|
56
64
|
}
|
|
57
|
-
return
|
|
65
|
+
return {
|
|
66
|
+
entityMetas: mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0],
|
|
67
|
+
features: mod.FEATURES,
|
|
68
|
+
};
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
function nextSequenceNumber(migrationsDir: string): number {
|
|
@@ -132,7 +143,7 @@ export async function runSchemaCli(
|
|
|
132
143
|
out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
|
|
133
144
|
return 1;
|
|
134
145
|
}
|
|
135
|
-
const metas = await
|
|
146
|
+
const { entityMetas: metas } = await loadSchemaModFromApp(schemaFile);
|
|
136
147
|
const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
|
|
137
148
|
const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
|
|
138
149
|
const result = generateMigration({
|
|
@@ -191,20 +202,18 @@ export async function runSchemaCli(
|
|
|
191
202
|
out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
|
|
192
203
|
return 1;
|
|
193
204
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
out.err(
|
|
200
|
-
` Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
|
|
201
|
-
);
|
|
205
|
+
let schemaMod: SchemaMod;
|
|
206
|
+
try {
|
|
207
|
+
schemaMod = await loadSchemaModFromApp(schemaFile);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
out.err(` ${e instanceof Error ? e.message : String(e)}`);
|
|
202
210
|
return 1;
|
|
203
211
|
}
|
|
212
|
+
const mod = { FEATURES: schemaMod.features };
|
|
204
213
|
let ok = true;
|
|
205
214
|
|
|
206
215
|
// 1. Schema drift — compute the diff, never write.
|
|
207
|
-
const metas =
|
|
216
|
+
const metas = schemaMod.entityMetas;
|
|
208
217
|
const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
|
|
209
218
|
const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
|
|
210
219
|
const drift = generateMigration({
|
|
@@ -32,6 +32,18 @@ export type _R6TypeAssertions = [
|
|
|
32
32
|
// the backstop. This is what keeps generic-over-response handlers compiling.
|
|
33
33
|
Expect<Equal<ContainsSecret<unknown>, false>>,
|
|
34
34
|
Expect<Equal<ContainsSecret<never>, false>>,
|
|
35
|
+
// 556/3: ContainsSecret<A | B> distributes over the union (naked type
|
|
36
|
+
// param) to `ContainsSecret<A> | ContainsSecret<B>` = `boolean`, not the
|
|
37
|
+
// literal `true` — pinned here so a future change to this predicate can't
|
|
38
|
+
// silently regress it. The actual leak-guard in define-handler.ts compares
|
|
39
|
+
// via `true extends ContainsSecret<TData>` (556/1) instead of `ContainsSecret<
|
|
40
|
+
// TData> extends true`, which is fail-closed for `boolean` either way.
|
|
41
|
+
Expect<Equal<ContainsSecret<{ ok: true } | { s: Secret<string> }>, boolean>>,
|
|
42
|
+
// 556/2: Map/Set are explicit SafeLeaf members now — pins that the runtime
|
|
43
|
+
// guard's "walk Map/Set entries separately" intent is mirrored, not an
|
|
44
|
+
// accidental compile-time blind spot.
|
|
45
|
+
Expect<Equal<ContainsSecret<Map<string, Secret<string>>>, false>>,
|
|
46
|
+
Expect<Equal<ContainsSecret<Set<Secret<string>>>, false>>,
|
|
35
47
|
];
|
|
36
48
|
|
|
37
49
|
const schema = z.object({ q: z.string() });
|
|
@@ -57,6 +69,28 @@ function genericResponseHandler<K extends string>(kind: K) {
|
|
|
57
69
|
}
|
|
58
70
|
void genericResponseHandler;
|
|
59
71
|
|
|
72
|
+
// 556/1: ContainsSecret<A | B> distributes over the union (naked type param
|
|
73
|
+
// in the condition) to `ContainsSecret<A> | ContainsSecret<B>` = `boolean`,
|
|
74
|
+
// not the literal `true` — a check written as `ContainsSecret<TData> extends
|
|
75
|
+
// true` silently passes a union with a leak hidden in ONE branch. Both
|
|
76
|
+
// assertions below must hold: the guard rejects the leaking union AND still
|
|
77
|
+
// accepts the clean one, proving the fix (membership form on the phantom
|
|
78
|
+
// param) is fail-closed for unions, not just for a bare Secret<>.
|
|
79
|
+
declare const leakingUnion: { ok: true } | { apiKey: Secret<string> };
|
|
80
|
+
// @ts-expect-error — R6: a Secret<> hidden in one union branch must still leak-guard
|
|
81
|
+
defineQueryHandler({
|
|
82
|
+
name: "t:query:union-leak",
|
|
83
|
+
schema,
|
|
84
|
+
handler: async () => leakingUnion,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
declare const cleanUnion: { ok: true } | { id: string };
|
|
88
|
+
defineQueryHandler({
|
|
89
|
+
name: "t:query:union-clean",
|
|
90
|
+
schema,
|
|
91
|
+
handler: async () => cleanUnion,
|
|
92
|
+
});
|
|
93
|
+
|
|
60
94
|
describe("R6 ContainsSecret", () => {
|
|
61
95
|
test("a clean query handler still builds — the guard param is invisible", () => {
|
|
62
96
|
const def = defineQueryHandler({
|
package/src/secrets/types.ts
CHANGED
|
@@ -56,6 +56,11 @@ type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
|
|
56
56
|
|
|
57
57
|
// Opaque built-in leaves a response legitimately carries; never recurse into
|
|
58
58
|
// them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
|
|
59
|
+
// Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
|
|
60
|
+
// already fell through to `false` via the object-mapped-type branch — listed
|
|
61
|
+
// explicitly here so the compile-time treatment matches leak-guard.ts's
|
|
62
|
+
// runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
|
|
63
|
+
// instead of looking like an oversight.
|
|
59
64
|
type SafeLeaf =
|
|
60
65
|
| Date
|
|
61
66
|
| RegExp
|
|
@@ -66,7 +71,9 @@ type SafeLeaf =
|
|
|
66
71
|
| Temporal.PlainTime
|
|
67
72
|
| Temporal.PlainYearMonth
|
|
68
73
|
| Temporal.PlainMonthDay
|
|
69
|
-
| Temporal.Duration
|
|
74
|
+
| Temporal.Duration
|
|
75
|
+
| Map<unknown, unknown>
|
|
76
|
+
| Set<unknown>;
|
|
70
77
|
|
|
71
78
|
export type ContainsSecret<T> = [T] extends [never]
|
|
72
79
|
? false
|
|
@@ -32,7 +32,10 @@ function resolveClearableTable(table: ClearableTable): unknown {
|
|
|
32
32
|
/** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
|
|
33
33
|
export async function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
|
|
34
34
|
for (const table of tables) {
|
|
35
|
-
|
|
35
|
+
// Test-teardown truncate: resolveClearableTable returns `unknown` (string
|
|
36
|
+
// name or table object). The brand-strip cast is the sanctioned bypass —
|
|
37
|
+
// clearing a managed projection between tests is not a production write.
|
|
38
|
+
await deleteMany(db, resolveClearableTable(table) as EntityTableMeta, {});
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
|
package/src/testing/index.ts
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Test-only ES-bypass: seed / mutate a managed projection directly, skipping
|
|
2
|
+
// the event log. Production code CANNOT do this — the write helpers reject a
|
|
3
|
+
// branded EntityTable at compile time (#742). Tests seed read-model state to
|
|
4
|
+
// arrange a scenario without driving the full event path; a projection rebuild
|
|
5
|
+
// is not part of the test lifecycle, so eventless writes are safe here. The
|
|
6
|
+
// `as EntityTableMeta` strips the executor-only brand at this single sanctioned
|
|
7
|
+
// seam — the signatures mirror the query helpers so a call-site migration is a
|
|
8
|
+
// plain identifier rename.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AnyDb,
|
|
12
|
+
deleteMany,
|
|
13
|
+
insertMany,
|
|
14
|
+
insertOne,
|
|
15
|
+
updateMany,
|
|
16
|
+
type WhereObject,
|
|
17
|
+
} from "../bun-db/query";
|
|
18
|
+
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
19
|
+
import type { EntityTable } from "../db/table-builder";
|
|
20
|
+
|
|
21
|
+
type SeedTable = EntityTable | EntityTableMeta;
|
|
22
|
+
|
|
23
|
+
export function seedRow<TRow = unknown>(
|
|
24
|
+
db: AnyDb,
|
|
25
|
+
table: SeedTable,
|
|
26
|
+
values: Record<string, unknown>,
|
|
27
|
+
): Promise<TRow | undefined> {
|
|
28
|
+
return insertOne<TRow>(db, table as EntityTableMeta, values);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function seedRows<TRow = unknown>(
|
|
32
|
+
db: AnyDb,
|
|
33
|
+
table: SeedTable,
|
|
34
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
35
|
+
): Promise<readonly TRow[]> {
|
|
36
|
+
return insertMany<TRow>(db, table as EntityTableMeta, rows);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updateRows<TRow = unknown>(
|
|
40
|
+
db: AnyDb,
|
|
41
|
+
table: SeedTable,
|
|
42
|
+
set: Record<string, unknown>,
|
|
43
|
+
where: WhereObject,
|
|
44
|
+
): Promise<readonly TRow[]> {
|
|
45
|
+
return updateMany<TRow>(db, table as EntityTableMeta, set, where);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<void> {
|
|
49
|
+
return deleteMany(db, table as EntityTableMeta, where);
|
|
50
|
+
}
|
|
@@ -9,6 +9,13 @@ describe("warnIfNonUtcServerTimeZone", () => {
|
|
|
9
9
|
expect(messages).toHaveLength(0);
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
test.each(["GMT", "Etc/UTC"])("warnt nicht bei UTC-äquivalenter Zone %s", (zone) => {
|
|
13
|
+
const messages: string[] = [];
|
|
14
|
+
const warned = warnIfNonUtcServerTimeZone(zone, (m) => messages.push(m));
|
|
15
|
+
expect(warned).toBe(false);
|
|
16
|
+
expect(messages).toHaveLength(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
12
19
|
test("warnt bei nicht-UTC Zone, nennt Zone + UTC-Hinweis", () => {
|
|
13
20
|
const messages: string[] = [];
|
|
14
21
|
const warned = warnIfNonUtcServerTimeZone("Europe/Berlin", (m) => messages.push(m));
|
|
@@ -18,7 +25,22 @@ describe("warnIfNonUtcServerTimeZone", () => {
|
|
|
18
25
|
expect(messages[0]).toContain("UTC");
|
|
19
26
|
});
|
|
20
27
|
|
|
21
|
-
test("Default-Aufruf liest die Prozess-TZ
|
|
22
|
-
|
|
28
|
+
test("Default-Aufruf liest die ECHTE Prozess-TZ (ambient, nicht injiziert)", () => {
|
|
29
|
+
// Das ist der einzige Test der ambient-TZ liest statt sie zu injizieren —
|
|
30
|
+
// der Grund warum der tz-matrix-CI-Job (4 Legs: LA/Berlin/Tokyo/Apia)
|
|
31
|
+
// überhaupt einen Unterschied zwischen den Legs sehen kann. Ohne diese
|
|
32
|
+
// Assertion wäre "läuft in jeder Zone ohne zu werfen" in allen 4 Legs
|
|
33
|
+
// identisch grün — die beworbene Schutzwirkung ("ein `new Date(wallClock)`-
|
|
34
|
+
// Bug bricht HIER") würde nicht existieren.
|
|
35
|
+
const ambientTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
36
|
+
const messages: string[] = [];
|
|
37
|
+
const warned = warnIfNonUtcServerTimeZone(undefined, (m) => messages.push(m));
|
|
38
|
+
if (ambientTz === "UTC") {
|
|
39
|
+
expect(warned).toBe(false);
|
|
40
|
+
expect(messages).toHaveLength(0);
|
|
41
|
+
} else {
|
|
42
|
+
expect(warned).toBe(true);
|
|
43
|
+
expect(messages[0]).toContain(ambientTz);
|
|
44
|
+
}
|
|
23
45
|
});
|
|
24
46
|
});
|
|
@@ -55,11 +55,17 @@ describe("ctx.tz — GeoTzProvider seam", () => {
|
|
|
55
55
|
test("fromAddress wirft wenn der Provider kein fromAddress hat (offline lat/lng)", async () => {
|
|
56
56
|
const provider: GeoTzProvider = { fromCoordinates: () => "UTC" };
|
|
57
57
|
const tz = createTzContext({ geoTz: provider });
|
|
58
|
-
|
|
58
|
+
// Regression (680/2): this message must say the PROVIDER lacks
|
|
59
|
+
// fromAddress, not the misleading "no provider configured" message.
|
|
60
|
+
await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
|
|
61
|
+
/GeoTzProvider that implements fromAddress/,
|
|
62
|
+
);
|
|
59
63
|
});
|
|
60
64
|
|
|
61
|
-
test("fromAddress wirft ohne Provider", async () => {
|
|
65
|
+
test("fromAddress wirft ohne Provider — eigene Message, nicht die Provider-ohne-fromAddress-Message", async () => {
|
|
62
66
|
const tz = createTzContext();
|
|
63
|
-
await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
|
|
67
|
+
await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
|
|
68
|
+
/requires a GeoTzProvider — inject one/,
|
|
69
|
+
);
|
|
64
70
|
});
|
|
65
71
|
});
|
|
@@ -25,6 +25,15 @@ describe("isValidIanaTimeZone", () => {
|
|
|
25
25
|
expect(isValidIanaTimeZone(value)).toBe(false);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// Intl.supportedValuesOf("timeZone") listet nur kanonische Namen — gültige
|
|
29
|
+
// IANA-Aliase fehlen darin, obwohl Intl.DateTimeFormat/Temporal/ctx.tz.parse
|
|
30
|
+
// sie klaglos akzeptieren. Ein valider Alias darf hier nicht als "invalid"
|
|
31
|
+
// rejected werden (stiller Breaking-Change für Consumer, die Alias-Zonen
|
|
32
|
+
// speichern).
|
|
33
|
+
test.each(["US/Pacific", "GMT", "Etc/UTC"])("akzeptiert gültigen IANA-Alias %s", (zone) => {
|
|
34
|
+
expect(isValidIanaTimeZone(zone)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
28
37
|
test("liefert über mehrere Aufrufe konsistent (lazy Set gecacht)", () => {
|
|
29
38
|
expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
|
|
30
39
|
expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
|
|
@@ -17,7 +17,11 @@ export function warnIfNonUtcServerTimeZone(
|
|
|
17
17
|
// biome-ignore lint/suspicious/noConsole: boot-time warning, no logger wired this early
|
|
18
18
|
warn: (message: string) => void = console.warn,
|
|
19
19
|
): boolean {
|
|
20
|
-
|
|
20
|
+
// GMT and Etc/UTC are UTC-equivalent (no offset, no DST) — TZ=GMT is a
|
|
21
|
+
// legitimate way to pin a process to UTC and must not trip this warning.
|
|
22
|
+
if (resolvedTimeZone === "UTC" || resolvedTimeZone === "GMT" || resolvedTimeZone === "Etc/UTC") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
21
25
|
warn(
|
|
22
26
|
`[kumiko] Server time zone is "${resolvedTimeZone}" — the framework assumes UTC. ` +
|
|
23
27
|
"Set TZ=UTC for the server process to avoid time-zone-dependent bugs.",
|
package/src/time/iana.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
// IANA-Zonenname-Validierung
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return supportedZones;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** True wenn `value` ein gültiger kanonischer IANA-Zonenname ist (z.B. "Europe/Berlin", "UTC"). */
|
|
1
|
+
// IANA-Zonenname-Validierung. Intl.supportedValuesOf("timeZone") liefert nur
|
|
2
|
+
// KANONISCHE Namen — gültige Aliase wie "US/Pacific", "GMT", "Etc/UTC" fehlen
|
|
3
|
+
// darin, obwohl Intl.DateTimeFormat, Temporal und ctx.tz.parse sie alle
|
|
4
|
+
// klaglos akzeptieren. Intl.DateTimeFormat selbst ist aber case-INSENSITIVE
|
|
5
|
+
// ("europe/berlin" resolved klaglos zu "Europe/Berlin") — ein reines
|
|
6
|
+
// try/catch würde also Tippfehler in der Groß-/Kleinschreibung durchlassen.
|
|
7
|
+
// Der exakte Vergleich mit resolvedOptions().timeZone schließt das: ein
|
|
8
|
+
// gültiger kanonischer Name ODER Alias resolved IMMER zu sich selbst
|
|
9
|
+
// (empirisch verifiziert für US/Pacific, GMT, Etc/UTC), ein falsch-gecasteter
|
|
10
|
+
// String resolved zur kanonischen Form und matcht damit nicht mehr exakt.
|
|
15
11
|
export function isValidIanaTimeZone(value: string): boolean {
|
|
16
|
-
|
|
12
|
+
try {
|
|
13
|
+
return (
|
|
14
|
+
new Intl.DateTimeFormat(undefined, { timeZone: value }).resolvedOptions().timeZone === value
|
|
15
|
+
);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
17
19
|
}
|
package/src/time/tz-context.ts
CHANGED
|
@@ -115,7 +115,12 @@ export function createTzContext(options: TzContextOptions = {}): TzContext {
|
|
|
115
115
|
return geoTz.fromCoordinates(coords);
|
|
116
116
|
},
|
|
117
117
|
fromAddress: async (address) => {
|
|
118
|
-
if (geoTz
|
|
118
|
+
if (geoTz === undefined) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"ctx.tz.fromAddress requires a GeoTzProvider — inject one via the app context (e.g. buildServer({ context: { geoTzProvider } }) or runProdApp({ extraContext: { geoTzProvider } })) or install a provider package.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (geoTz.fromAddress === undefined) {
|
|
119
124
|
throw new Error(
|
|
120
125
|
"ctx.tz.fromAddress requires a GeoTzProvider that implements fromAddress (geocoding). Offline lat/lng providers only support fromCoordinates.",
|
|
121
126
|
);
|
|
@@ -21,4 +21,10 @@ describe("parseRoles", () => {
|
|
|
21
21
|
expect(parseRoles(42)).toEqual([]);
|
|
22
22
|
expect(parseRoles({ roles: ["Admin"] })).toEqual([]);
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
test("filters out non-string entries instead of returning a type-lying array (517/1)", () => {
|
|
26
|
+
expect(parseRoles([42, "Admin", true, null])).toEqual(["Admin"]);
|
|
27
|
+
expect(parseRoles("[42, true]")).toEqual([]);
|
|
28
|
+
expect(parseRoles('[42, "Admin"]')).toEqual(["Admin"]);
|
|
29
|
+
});
|
|
24
30
|
});
|