@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +165 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +17 -1
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +32 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -298,3 +298,90 @@ export function createJobTriggerEventConsumer(
|
|
|
298
298
|
},
|
|
299
299
|
};
|
|
300
300
|
}
|
|
301
|
+
|
|
302
|
+
// --- Access-Invalidation Consumer (async, via event-dispatcher) ---
|
|
303
|
+
//
|
|
304
|
+
// #1524 (Design), #1558 (channel + stream subscribe), #1559 (session-revoke
|
|
305
|
+
// emits an event). This consumer is the last leg: it watches for the two
|
|
306
|
+
// event types that can make an already-issued JWT stale mid-stream and
|
|
307
|
+
// pushes an invalidation through sseBroker.publishAccessInvalidation, which
|
|
308
|
+
// dispatch-stream.ts's subscribeAccessInvalidation listener turns into an
|
|
309
|
+
// AccessDeniedError thrown into the open stream.
|
|
310
|
+
//
|
|
311
|
+
// Event types are literal strings, not imports — this package (framework)
|
|
312
|
+
// cannot depend on bundled-features (sessions, tenant own these events),
|
|
313
|
+
// mirrors the es-ops-seed precedent (literal QNs, no subpath import).
|
|
314
|
+
//
|
|
315
|
+
// "sessions:event:session-revoked" — payload.userId direct (own aggregate,
|
|
316
|
+
// see bundled-features/sessions/session-revoked-event.ts). Fired for
|
|
317
|
+
// both self-service revoke and the privileged cross-tenant
|
|
318
|
+
// revoke-all-for-user (SYSTEM_TENANT_ID-anchored DSGVO Art.18 freeze).
|
|
319
|
+
// fetchPendingEvents has no tenant predicate, so the SYSTEM_TENANT_ID-
|
|
320
|
+
// anchored event reaches this consumer the same as any other — routing
|
|
321
|
+
// is purely on payload.userId, never on event.tenantId.
|
|
322
|
+
// "tenant-membership.updated" / "tenant-membership.deleted" — role change
|
|
323
|
+
// or member removal. userId isn't in payload.changes (update only
|
|
324
|
+
// carries the changed fields, e.g. { roles }) so it's read from
|
|
325
|
+
// payload.previous, the full pre-write entity snapshot written by
|
|
326
|
+
// createEventStoreExecutor. tenantMembershipEntity declares no
|
|
327
|
+
// encrypted/PII fields, so previous.userId is plaintext — no KMS
|
|
328
|
+
// decrypt step (see kumiko-framework#1560 PR discussion; the issue's
|
|
329
|
+
// original handoff assumed encryption that doesn't apply here).
|
|
330
|
+
//
|
|
331
|
+
// Over-invalidation (e.g. tenant-membership.updated fired by something
|
|
332
|
+
// other than a role change) is fail-safe: the stream closes, the client
|
|
333
|
+
// reconnects and re-authorizes. No opt-out/allowlist — #1524 chose
|
|
334
|
+
// global-by-default specifically so this can't be silently disabled per
|
|
335
|
+
// handler.
|
|
336
|
+
//
|
|
337
|
+
// Scope note: entityEventName also emits "tenant-membership.forgotten" (DSGVO
|
|
338
|
+
// erasure) and ".restored" — .restored re-grants access so ignoring it is
|
|
339
|
+
// correct, .forgotten is not currently produced by any user-data-rights
|
|
340
|
+
// pipeline for memberships but would be revocation-relevant if it ever is.
|
|
341
|
+
// Out of #1560's stated scope (session-revoke + role/membership change),
|
|
342
|
+
// tracked rather than handled speculatively here.
|
|
343
|
+
export const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
|
|
344
|
+
|
|
345
|
+
const SESSION_REVOKED_EVENT_TYPE = "sessions:event:session-revoked";
|
|
346
|
+
const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
|
|
347
|
+
const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
|
|
348
|
+
|
|
349
|
+
function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): string | undefined {
|
|
350
|
+
const previous = payload["previous"];
|
|
351
|
+
if (typeof previous !== "object" || previous === null) return undefined;
|
|
352
|
+
const userId = (previous as Record<string, unknown>)["userId"];
|
|
353
|
+
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
|
|
357
|
+
return {
|
|
358
|
+
name: ACCESS_INVALIDATION_CONSUMER_NAME,
|
|
359
|
+
// Per-instance, same reasoning as SSE broadcast: subscribeAccessInvalidation
|
|
360
|
+
// listeners live in this process's in-memory sseBroker only. A shared
|
|
361
|
+
// cursor would deliver the event to exactly one instance and leave
|
|
362
|
+
// every other instance's open streams for that user un-invalidated.
|
|
363
|
+
delivery: "per-instance",
|
|
364
|
+
handler: async (event) => {
|
|
365
|
+
if (event.type === SESSION_REVOKED_EVENT_TYPE) {
|
|
366
|
+
const userId = event.payload["userId"];
|
|
367
|
+
// skip: malformed session-revoked payload — fail open on this one
|
|
368
|
+
// event rather than dead-lettering the whole consumer (halt-on-
|
|
369
|
+
// poison would otherwise permanently stop access-invalidation for
|
|
370
|
+
// every user behind one bad row).
|
|
371
|
+
if (typeof userId !== "string" || userId.length === 0) return;
|
|
372
|
+
sseBroker.publishAccessInvalidation(userId);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (
|
|
376
|
+
event.type === TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE ||
|
|
377
|
+
event.type === TENANT_MEMBERSHIP_DELETED_EVENT_TYPE
|
|
378
|
+
) {
|
|
379
|
+
const userId = readUserIdFromPreviousSnapshot(event.payload);
|
|
380
|
+
// skip: previous snapshot missing/malformed userId — same fail-open
|
|
381
|
+
// reasoning as above.
|
|
382
|
+
if (userId === undefined) return;
|
|
383
|
+
sseBroker.publishAccessInvalidation(userId);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
@@ -169,6 +169,24 @@ describe("createRateLimitResolver — peek", () => {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
describe("createRateLimitResolver — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
173
|
+
test("check() and peek() compute resetAt without relying on globalThis.Temporal", async () => {
|
|
174
|
+
const config = { limit: 5, windowSeconds: 60 };
|
|
175
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
176
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
177
|
+
try {
|
|
178
|
+
const checked = await resolver.check("no-ambient:user", config);
|
|
179
|
+
expect(checked.resetAt).toBeDefined();
|
|
180
|
+
|
|
181
|
+
const peeked = await resolver.peek("no-ambient:user", config);
|
|
182
|
+
expect(peeked.resetAt).toBeDefined();
|
|
183
|
+
} finally {
|
|
184
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
185
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
172
190
|
describe("createRateLimitResolver — enforce", () => {
|
|
173
191
|
test("enforce throws RateLimitError with the bucket details when blocked", async () => {
|
|
174
192
|
const config = { limit: 1, windowSeconds: 60 };
|
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
RateLimitResolver,
|
|
5
5
|
} from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
6
6
|
import type Redis from "ioredis";
|
|
7
|
+
// Value-only import, aliased to avoid shadowing the ambient global
|
|
8
|
+
// `Temporal` TYPE that RateLimitDecision.resetAt resolves against (see
|
|
9
|
+
// event-store.ts for the same #1438 dual-package-hazard pattern).
|
|
10
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
7
11
|
import { RateLimitError } from "../errors";
|
|
8
12
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
9
13
|
|
|
@@ -189,7 +193,7 @@ export function createRateLimitResolver(opts: RateLimitResolverOptions): RateLim
|
|
|
189
193
|
);
|
|
190
194
|
|
|
191
195
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
192
|
-
const resetAt =
|
|
196
|
+
const resetAt = TemporalPolyfill.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
|
|
193
197
|
|
|
194
198
|
return {
|
|
195
199
|
allowed: allowedFlag === 1,
|
|
@@ -229,7 +233,7 @@ export function createRateLimitResolver(opts: RateLimitResolverOptions): RateLim
|
|
|
229
233
|
);
|
|
230
234
|
|
|
231
235
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
232
|
-
const resetAt =
|
|
236
|
+
const resetAt = TemporalPolyfill.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
|
|
233
237
|
|
|
234
238
|
return {
|
|
235
239
|
// peek doesn't deduct, so a "would-be" allowed flag is meaningful:
|
package/src/schema-cli.ts
CHANGED
|
@@ -275,20 +275,32 @@ export async function runSchemaCli(
|
|
|
275
275
|
// diff the reconstructed schema against .snapshot.json.
|
|
276
276
|
const committedSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
|
|
277
277
|
if (existsSync(migrationsDir) && committedSnapshot !== null) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
278
|
+
try {
|
|
279
|
+
const replayed = replayMigrationsDir(migrationsDir);
|
|
280
|
+
const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
|
|
281
|
+
if (mismatches.length === 0) {
|
|
282
|
+
out.log(" ✓ migrations: table/column names match .snapshot.json");
|
|
283
|
+
} else {
|
|
284
|
+
ok = false;
|
|
285
|
+
out.err(
|
|
286
|
+
" ✗ migration-content drift: committed *.sql files don't produce .snapshot.json.",
|
|
287
|
+
);
|
|
288
|
+
for (const m of mismatches) {
|
|
289
|
+
out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
|
|
290
|
+
}
|
|
291
|
+
out.err(
|
|
292
|
+
" Fix: a migration file's body doesn't match what it (or the snapshot) claims — hand-fix the file, or ship a corrective migration if it's already applied in prod.",
|
|
293
|
+
);
|
|
289
294
|
}
|
|
295
|
+
} catch (e) {
|
|
296
|
+
// replayMigrationsDir fail-loud's on a table-DDL statement it can't
|
|
297
|
+
// parse — that must surface as a normal ✗ line like every other
|
|
298
|
+
// check here, not a raw stack trace that kills the process before
|
|
299
|
+
// any later check (or the Fix: help) ever runs (framework#1535).
|
|
300
|
+
ok = false;
|
|
301
|
+
out.err(` ✗ migration-content: ${e instanceof Error ? e.message : String(e)}`);
|
|
290
302
|
out.err(
|
|
291
|
-
" Fix: a migration file
|
|
303
|
+
" Fix: a statement in a migration file isn't recognizable to the replay parser — extend the parser's recognized patterns, or reword the migration to one it understands.",
|
|
292
304
|
);
|
|
293
305
|
}
|
|
294
306
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
8
|
import {
|
|
9
|
+
asRawClient,
|
|
9
10
|
buildEntityTable,
|
|
10
11
|
createEventStoreExecutor,
|
|
11
12
|
createTenantDb,
|
|
@@ -111,11 +112,33 @@ describe("reindexEntity", () => {
|
|
|
111
112
|
admin.tenantId,
|
|
112
113
|
{ dryRun: true },
|
|
113
114
|
);
|
|
114
|
-
expect(result.indexedRows).
|
|
115
|
+
expect(result.indexedRows).toBe(0);
|
|
116
|
+
expect(result.wouldIndexRows).toBeGreaterThan(0);
|
|
115
117
|
|
|
116
118
|
const postResults = await stack.search.search(admin.tenantId, "dryrun", {
|
|
117
119
|
filterType: "widget",
|
|
118
120
|
});
|
|
119
121
|
expect(postResults).toHaveLength(0);
|
|
120
122
|
});
|
|
123
|
+
|
|
124
|
+
// kumiko-framework#1549: a searchable field with no matching read-table
|
|
125
|
+
// column (dropped/never-migrated) used to push one identical failures[]
|
|
126
|
+
// entry per scanned row instead of failing fast — fails loud on the first
|
|
127
|
+
// row's column set instead, which is invariant across the whole scan.
|
|
128
|
+
test("throws immediately when a searchable field has no matching column, without scanning every row", async () => {
|
|
129
|
+
const executor = seedExecutor();
|
|
130
|
+
await executor.create({ name: "Row A" }, admin, tenantDb());
|
|
131
|
+
await executor.create({ name: "Row B" }, admin, tenantDb());
|
|
132
|
+
|
|
133
|
+
await asRawClient(stack.db).unsafe(`ALTER TABLE "read_reindex_widgets" DROP COLUMN "name"`);
|
|
134
|
+
try {
|
|
135
|
+
await expect(
|
|
136
|
+
reindexEntity(stack.db, stack.registry, stack.search, "widget", admin.tenantId),
|
|
137
|
+
).rejects.toThrow(/searchable field "name" is not mappable/);
|
|
138
|
+
} finally {
|
|
139
|
+
await asRawClient(stack.db).unsafe(
|
|
140
|
+
`ALTER TABLE "read_reindex_widgets" ADD COLUMN "name" text`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
121
144
|
});
|
|
@@ -21,6 +21,9 @@ export type ReindexEntityFailure = {
|
|
|
21
21
|
export type ReindexEntityResult = {
|
|
22
22
|
readonly scannedRows: number;
|
|
23
23
|
readonly indexedRows: number;
|
|
24
|
+
// Only nonzero in a dry run — docs that would have been indexed. In a
|
|
25
|
+
// real run indexedRows already counts these; wouldIndexRows stays 0.
|
|
26
|
+
readonly wouldIndexRows: number;
|
|
24
27
|
readonly failures: readonly ReindexEntityFailure[];
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -82,9 +85,23 @@ export async function reindexEntity(
|
|
|
82
85
|
const deletedFilter =
|
|
83
86
|
entity.softDelete === true ? `AND ${quoteIdent("is_deleted")} IS NOT TRUE` : "";
|
|
84
87
|
|
|
85
|
-
const result = {
|
|
88
|
+
const result = {
|
|
89
|
+
scannedRows: 0,
|
|
90
|
+
indexedRows: 0,
|
|
91
|
+
wouldIndexRows: 0,
|
|
92
|
+
failures: [] as ReindexEntityFailure[],
|
|
93
|
+
};
|
|
86
94
|
|
|
87
95
|
let offset = 0;
|
|
96
|
+
// Whether a searchable field lacks a matching read-table column depends
|
|
97
|
+
// only on the table's column set (rowToState/Object.hasOwn), not on any
|
|
98
|
+
// particular row's data — every row from the same SELECT * has the same
|
|
99
|
+
// columns. Check once against the first row instead of every row, so a
|
|
100
|
+
// dropped/never-migrated column fails fast with one clear error instead
|
|
101
|
+
// of one identical failures[] entry per scanned row (1M rows → 1M
|
|
102
|
+
// entries, and the real schema problem is buried in "x failed").
|
|
103
|
+
let uncheckedSchema = true;
|
|
104
|
+
|
|
88
105
|
for (;;) {
|
|
89
106
|
// ponytail: LIMIT/OFFSET, not a keyset cursor — id can be uuid or
|
|
90
107
|
// serial depending on the entity, and a uniform cursor comparison
|
|
@@ -101,6 +118,18 @@ export async function reindexEntity(
|
|
|
101
118
|
);
|
|
102
119
|
if (rows.length === 0) break;
|
|
103
120
|
|
|
121
|
+
if (uncheckedSchema) {
|
|
122
|
+
uncheckedSchema = false;
|
|
123
|
+
const sampleState = rowToState(rows[0] as Record<string, unknown>, fieldNames);
|
|
124
|
+
const unmappedField = searchableFields.find((fieldName) => !(fieldName in sampleState));
|
|
125
|
+
if (unmappedField !== undefined) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`reindexEntity: searchable field "${unmappedField}" is not mappable from read-table ` +
|
|
128
|
+
`column ${quoteIdent(tableName)} — likely a dropped/never-migrated column.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
104
133
|
const docs: Array<{ entityId: string; doc: SearchDocument }> = [];
|
|
105
134
|
for (const row of rows) {
|
|
106
135
|
result.scannedRows++;
|
|
@@ -137,7 +166,7 @@ export async function reindexEntity(
|
|
|
137
166
|
}
|
|
138
167
|
}
|
|
139
168
|
} else if (options.dryRun) {
|
|
140
|
-
result.
|
|
169
|
+
result.wouldIndexRows += docs.length;
|
|
141
170
|
}
|
|
142
171
|
|
|
143
172
|
offset += rows.length;
|
package/src/search/types.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/search-adapter";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/search-adapter";
|
|
@@ -99,7 +99,7 @@ describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", ()
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("setupTestStack({ jobs }) job context matches the request-path context", () => {
|
|
102
|
-
test("job handler's context carries tracer + searchAdapter
|
|
102
|
+
test("job handler's context carries tracer + searchAdapter, mirrors prod's effectiveFeatures-less job context", async () => {
|
|
103
103
|
jobContextFields.length = 0;
|
|
104
104
|
stack = await setupTestStack({
|
|
105
105
|
features: [jobContextFeature],
|
|
@@ -112,10 +112,14 @@ describe("setupTestStack({ jobs }) job context matches the request-path context"
|
|
|
112
112
|
await waitFor(() => {
|
|
113
113
|
expect(jobContextFields.length).toBeGreaterThan(0);
|
|
114
114
|
});
|
|
115
|
+
// #1255: effectiveFeatures deliberately stays OUT of the job/appContext
|
|
116
|
+
// (prod only carries it in dispatcherOptions.effectiveFeatures, consumed
|
|
117
|
+
// by the command-dispatcher) — putting it here would let a job read
|
|
118
|
+
// `ctx.effectiveFeatures` green in tests and break on deploy.
|
|
115
119
|
expect(jobContextFields[0]).toEqual({
|
|
116
120
|
hasTracer: true,
|
|
117
121
|
hasSearchAdapter: true,
|
|
118
|
-
hasEffectiveFeatures:
|
|
122
|
+
hasEffectiveFeatures: false,
|
|
119
123
|
});
|
|
120
124
|
});
|
|
121
125
|
});
|
package/src/stack/db.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Provider-agnostic via createConnection (DB_PROVIDER env).
|
|
3
3
|
// postgres-js = default. DB_PROVIDER=bun = Bun.SQL (experimentell).
|
|
4
4
|
|
|
5
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
|
|
5
6
|
import { createConnection } from "../db/api";
|
|
6
7
|
import { createDatabase, databaseExists, dropDatabaseIfExists } from "../db/queries/test-stack";
|
|
7
8
|
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
@@ -18,7 +19,7 @@ function requireEnv(name: string): string {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export type TestDb = {
|
|
21
|
-
db:
|
|
22
|
+
db: DbConnection;
|
|
22
23
|
client: unknown;
|
|
23
24
|
dbName: string;
|
|
24
25
|
cleanup: () => Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
1
2
|
import { tableExists } from "../db/schema-inspection";
|
|
2
3
|
import type { Registry } from "../engine/types";
|
|
3
4
|
import { unsafePushTables } from "./table-helpers";
|
|
@@ -35,7 +36,7 @@ export async function pushEntityProjectionTables(
|
|
|
35
36
|
if (seen.has(proj.table)) continue;
|
|
36
37
|
seen.add(proj.table);
|
|
37
38
|
const tableRec = proj.table as unknown as Record<symbol, unknown>;
|
|
38
|
-
const physical = tableRec[
|
|
39
|
+
const physical = tableRec[KUMIKO_NAME_SYMBOL] as string;
|
|
39
40
|
if (await tableExists(stack.db, `public.${physical}`)) {
|
|
40
41
|
logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
|
|
41
42
|
continue;
|
|
@@ -111,10 +111,29 @@ export function createRequestHelper(
|
|
|
111
111
|
jwt: JwtHelper,
|
|
112
112
|
options: RequestHelperOptions = {},
|
|
113
113
|
): RequestHelper {
|
|
114
|
+
// sid per (user.id, tenantId), not one mint per authHeader() call:
|
|
115
|
+
// sessionCreator opens a live session row, so an unmemoized call mints a
|
|
116
|
+
// fresh one on every request a test makes for the same user — tests
|
|
117
|
+
// asserting on session counts / massRevoker / revokeAllOthers behavior
|
|
118
|
+
// then see extra live sids that have nothing to do with what they're
|
|
119
|
+
// testing. Keyed on tenantId too — the same user.id can hold sessions in
|
|
120
|
+
// more than one tenant, and sessionCreator writes the row scoped to
|
|
121
|
+
// `user.tenantId` (session-callbacks.ts), so a user.id-only key would
|
|
122
|
+
// hand a cross-tenant test its wrong tenant's sid.
|
|
123
|
+
// Cache the Promise (not the settled sid) so two concurrent authHeader
|
|
124
|
+
// calls for the same key share one mint instead of racing on a miss.
|
|
125
|
+
const sidByUserKey = new Map<string, Promise<string>>();
|
|
126
|
+
|
|
114
127
|
async function authHeader(user: SessionUser): Promise<Record<string, string>> {
|
|
115
128
|
let forJwt = user;
|
|
116
129
|
if (options.sessionCreator && !user.sid) {
|
|
117
|
-
const
|
|
130
|
+
const key = `${user.id}:${user.tenantId}`;
|
|
131
|
+
let sidPromise = sidByUserKey.get(key);
|
|
132
|
+
if (sidPromise === undefined) {
|
|
133
|
+
sidPromise = options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
|
|
134
|
+
sidByUserKey.set(key, sidPromise);
|
|
135
|
+
}
|
|
136
|
+
const sid = await sidPromise;
|
|
118
137
|
forJwt = { ...user, sid };
|
|
119
138
|
}
|
|
120
139
|
const token = await jwt.sign(forJwt);
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KUMIKO_META_SYMBOL,
|
|
3
|
+
KUMIKO_NAME_SYMBOL,
|
|
4
|
+
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
1
5
|
import type { DbConnection } from "../db/connection";
|
|
2
6
|
import { pgTypeToSqlType } from "../db/dialect";
|
|
3
7
|
import type { ColumnMeta, EntityTableMeta } from "../db/entity-table-meta";
|
|
@@ -5,15 +9,13 @@ import {
|
|
|
5
9
|
alterTableAddColumn,
|
|
6
10
|
createIndexIfNotExists,
|
|
7
11
|
executeDdlStatement,
|
|
8
|
-
|
|
9
|
-
} from "../db/queries/test-stack";
|
|
12
|
+
} from "../db/queries/ddl";
|
|
13
|
+
import { truncateTablesRestartIdentity } from "../db/queries/test-stack";
|
|
10
14
|
import { renderTableDdl } from "../db/render-ddl";
|
|
11
15
|
import { tableExists } from "../db/schema-inspection";
|
|
12
16
|
import { buildEntityTable, toTableName } from "../db/table-builder";
|
|
13
17
|
import type { EventDispatcher } from "../pipeline";
|
|
14
18
|
|
|
15
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
16
|
-
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
17
19
|
function tableNameOf(table: unknown): string {
|
|
18
20
|
if (typeof table !== "object" || table === null) {
|
|
19
21
|
throw new Error("table-helpers: table is not a SchemaTable object");
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Hono } from "hono";
|
|
|
2
2
|
import type { AuthRoutesConfig } from "../api/auth-routes";
|
|
3
3
|
import type { JwtHelper } from "../api/jwt";
|
|
4
4
|
import { buildServer } from "../api/server";
|
|
5
|
-
import { createSseBroker } from "../api/sse-broker";
|
|
5
|
+
import { createSseBroker, type SseBroker } from "../api/sse-broker";
|
|
6
6
|
import type { PgClient } from "../db/connection";
|
|
7
7
|
import { extractTableInfo } from "../db/query";
|
|
8
8
|
import { createRegistry } from "../engine/registry";
|
|
@@ -25,13 +25,17 @@ export type TestStack = {
|
|
|
25
25
|
app: Hono;
|
|
26
26
|
jwt: JwtHelper;
|
|
27
27
|
registry: Registry;
|
|
28
|
-
|
|
29
|
-
db: any;
|
|
28
|
+
db: import("../db").DbConnection;
|
|
30
29
|
redis: TestRedis;
|
|
31
30
|
search: SearchAdapter;
|
|
32
31
|
events: EventCollector;
|
|
33
32
|
http: RequestHelper;
|
|
34
33
|
observability: ObservabilityProvider;
|
|
34
|
+
// In-memory broker backing the server's SSE routes + access-invalidation
|
|
35
|
+
// channel. Tests subscribe directly (e.g. sseBroker.subscribeAccessInvalidation)
|
|
36
|
+
// to assert a consumer pushed an invalidation without opening a real SSE
|
|
37
|
+
// connection.
|
|
38
|
+
sseBroker: SseBroker;
|
|
35
39
|
// Command-dispatcher behind the HTTP routes — for direct system-writes
|
|
36
40
|
// in tests and dev-server extraRoutes (provider-webhook wiring).
|
|
37
41
|
dispatcher: Dispatcher;
|
|
@@ -66,8 +70,7 @@ export type TestStackOptions = {
|
|
|
66
70
|
| Record<string, unknown>
|
|
67
71
|
| ((deps: {
|
|
68
72
|
registry: Registry;
|
|
69
|
-
|
|
70
|
-
db: any;
|
|
73
|
+
db: import("../db").DbConnection;
|
|
71
74
|
sseBroker: import("../api/sse-broker").SseBroker;
|
|
72
75
|
redis: import("ioredis").default;
|
|
73
76
|
}) => Record<string, unknown>);
|
|
@@ -119,8 +122,7 @@ export type TestStackOptions = {
|
|
|
119
122
|
| import("../api/server").ServerOptions["anonymousAccess"]
|
|
120
123
|
| ((deps: {
|
|
121
124
|
registry: Registry;
|
|
122
|
-
|
|
123
|
-
db: any;
|
|
125
|
+
db: import("../db").DbConnection;
|
|
124
126
|
sseBroker: import("../api/sse-broker").SseBroker;
|
|
125
127
|
redis: import("ioredis").default;
|
|
126
128
|
}) => import("../api/server").ServerOptions["anonymousAccess"]);
|
|
@@ -130,8 +132,7 @@ export type TestStackOptions = {
|
|
|
130
132
|
base: import("../api/server").ServerOptions["anonymousAccess"] | undefined,
|
|
131
133
|
deps: {
|
|
132
134
|
registry: Registry;
|
|
133
|
-
|
|
134
|
-
db: any;
|
|
135
|
+
db: import("../db").DbConnection;
|
|
135
136
|
},
|
|
136
137
|
) => Promise<import("../api/server").ServerOptions["anonymousAccess"] | undefined>;
|
|
137
138
|
/** Opt-in JobRunner wired into ctx.jobRunner and merged into
|
|
@@ -268,11 +269,12 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
268
269
|
// (kumiko-framework#1232: a reduced `{ db, registry }` literal let jobs
|
|
269
270
|
// pass in tests while reaching for fields only prod's context has).
|
|
270
271
|
//
|
|
271
|
-
// effectiveFeatures
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
272
|
+
// effectiveFeatures deliberately stays OUT of appContext — prod never puts
|
|
273
|
+
// it there either (only dispatcherOptions.effectiveFeatures, consumed by
|
|
274
|
+
// the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
|
|
275
|
+
// index.ts). Putting it here would reintroduce the exact test-vs-prod
|
|
276
|
+
// drift #1232 fixed: a job/handler reading `ctx.effectiveFeatures` would
|
|
277
|
+
// pass in tests and break in prod (kumiko-framework#1255).
|
|
276
278
|
const appContext = {
|
|
277
279
|
db: testDb.db,
|
|
278
280
|
redis: testRedis.redis,
|
|
@@ -281,7 +283,6 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
281
283
|
registry,
|
|
282
284
|
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
283
285
|
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
284
|
-
...(options.effectiveFeatures ? { effectiveFeatures: options.effectiveFeatures } : {}),
|
|
285
286
|
...(typeof options.extraContext === "function"
|
|
286
287
|
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
287
288
|
: options.extraContext),
|
|
@@ -377,6 +378,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
377
378
|
systemConsumers: {
|
|
378
379
|
sse: enabledHooks.includes("sse"),
|
|
379
380
|
search: enabledHooks.includes("search"),
|
|
381
|
+
accessInvalidation: enabledHooks.includes("sse"),
|
|
380
382
|
},
|
|
381
383
|
},
|
|
382
384
|
// Default tests to no login rate-limiter so existing suites that loop
|
|
@@ -437,6 +439,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
437
439
|
events,
|
|
438
440
|
http,
|
|
439
441
|
observability: server.observability,
|
|
442
|
+
sseBroker,
|
|
440
443
|
dispatcher: server.dispatcher,
|
|
441
444
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
442
445
|
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
@@ -22,4 +22,11 @@ describe("createLateBoundHolder", () => {
|
|
|
22
22
|
holder.set("second");
|
|
23
23
|
expect(holder.get()).toBe("second");
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
test("set(undefined) still marks the holder ready", () => {
|
|
27
|
+
const holder = createLateBoundHolder<number | undefined>("opt");
|
|
28
|
+
holder.set(undefined);
|
|
29
|
+
expect(holder.isReady()).toBe(true);
|
|
30
|
+
expect(holder.get()).toBeUndefined();
|
|
31
|
+
});
|
|
25
32
|
});
|
|
@@ -39,6 +39,12 @@ describe("waitFor", () => {
|
|
|
39
39
|
expect(calls).toBe(2);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test("throws a descriptive error for an empty delay schedule", async () => {
|
|
43
|
+
await expect(waitFor(() => {}, { delays: [] })).rejects.toThrow(
|
|
44
|
+
"waitFor: empty delay schedule",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
42
48
|
test("supports an async fn", async () => {
|
|
43
49
|
let calls = 0;
|
|
44
50
|
await waitFor(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import type { FileStorageProvider } from "../files/types";
|
|
3
3
|
|
|
4
4
|
const bytes = (s: string) => new TextEncoder().encode(s);
|
|
@@ -18,19 +18,39 @@ export function describeFileProviderContract(
|
|
|
18
18
|
): void {
|
|
19
19
|
describe(`${name} — FileStorageProvider contract`, () => {
|
|
20
20
|
let provider: FileStorageProvider;
|
|
21
|
+
// Self-cleaning: every key this contract creates gets deleted in
|
|
22
|
+
// afterEach, so a persistent backend (local `kumiko dev` against a
|
|
23
|
+
// long-lived Minio, as opposed to CI's ephemeral one) doesn't leak
|
|
24
|
+
// `contract/<uuid>` objects into the next developer's session. delete()
|
|
25
|
+
// on a key that was never actually written is covered by the
|
|
26
|
+
// "no-op on a missing key" contract test above, so tracking generated
|
|
27
|
+
// keys unconditionally (even ones a test never got around to writing)
|
|
28
|
+
// is safe.
|
|
29
|
+
let writtenKeys: string[];
|
|
30
|
+
|
|
31
|
+
function trackedKey(ext: string): string {
|
|
32
|
+
const key = `contract/${crypto.randomUUID()}.${ext}`;
|
|
33
|
+
writtenKeys.push(key);
|
|
34
|
+
return key;
|
|
35
|
+
}
|
|
21
36
|
|
|
22
37
|
beforeEach(async () => {
|
|
23
38
|
provider = await factory();
|
|
39
|
+
writtenKeys = [];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
for (const key of writtenKeys) await provider.delete(key);
|
|
24
44
|
});
|
|
25
45
|
|
|
26
46
|
test("write + read roundtrip preserves bytes", async () => {
|
|
27
|
-
const key =
|
|
47
|
+
const key = trackedKey("bin");
|
|
28
48
|
await provider.write(key, bytes("hello contract"));
|
|
29
49
|
expect(decode(await provider.read(key))).toBe("hello contract");
|
|
30
50
|
});
|
|
31
51
|
|
|
32
52
|
test("write on an existing key overwrites it (last-write-wins)", async () => {
|
|
33
|
-
const key =
|
|
53
|
+
const key = trackedKey("bin");
|
|
34
54
|
await provider.write(key, bytes("first"));
|
|
35
55
|
await provider.write(key, bytes("second"));
|
|
36
56
|
expect(decode(await provider.read(key))).toBe("second");
|
|
@@ -41,7 +61,7 @@ export function describeFileProviderContract(
|
|
|
41
61
|
});
|
|
42
62
|
|
|
43
63
|
test("exists reflects write + delete", async () => {
|
|
44
|
-
const key =
|
|
64
|
+
const key = trackedKey("txt");
|
|
45
65
|
expect(await provider.exists(key)).toBe(false);
|
|
46
66
|
await provider.write(key, bytes("x"));
|
|
47
67
|
expect(await provider.exists(key)).toBe(true);
|
|
@@ -56,7 +76,7 @@ export function describeFileProviderContract(
|
|
|
56
76
|
});
|
|
57
77
|
|
|
58
78
|
test("writeStream + readStream roundtrip preserves bytes", async () => {
|
|
59
|
-
const key =
|
|
79
|
+
const key = trackedKey("stream");
|
|
60
80
|
await provider.writeStream(key, fromChunks([bytes("foo"), bytes("bar")]));
|
|
61
81
|
let out = "";
|
|
62
82
|
for await (const chunk of provider.readStream(key)) out += decode(chunk);
|
|
@@ -74,7 +94,7 @@ export function describeFileProviderContract(
|
|
|
74
94
|
// skip: getSignedUrl is optional on the contract — feature-detected
|
|
75
95
|
if (!provider.getSignedUrl) return;
|
|
76
96
|
|
|
77
|
-
const key =
|
|
97
|
+
const key = trackedKey("txt");
|
|
78
98
|
await provider.write(key, bytes("signed"));
|
|
79
99
|
const url = await provider.getSignedUrl(key, 60);
|
|
80
100
|
expect(typeof url).toBe("string");
|
package/src/testing/index.ts
CHANGED
|
@@ -22,18 +22,20 @@ export type LateBoundHolder<T> = {
|
|
|
22
22
|
|
|
23
23
|
export function createLateBoundHolder<T>(label = "value"): LateBoundHolder<T> {
|
|
24
24
|
let value: T | undefined;
|
|
25
|
+
let isSet = false;
|
|
25
26
|
return {
|
|
26
27
|
set(v) {
|
|
27
28
|
value = v;
|
|
29
|
+
isSet = true;
|
|
28
30
|
},
|
|
29
31
|
get() {
|
|
30
|
-
if (
|
|
32
|
+
if (!isSet) {
|
|
31
33
|
throw new Error(`late-bound ${label} accessed before set() was called`);
|
|
32
34
|
}
|
|
33
|
-
return value;
|
|
35
|
+
return value as T;
|
|
34
36
|
},
|
|
35
37
|
isReady() {
|
|
36
|
-
return
|
|
38
|
+
return isSet;
|
|
37
39
|
},
|
|
38
40
|
};
|
|
39
41
|
}
|