@cosmicdrift/kumiko-framework 0.220.0 → 0.221.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 +3 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/request-locale.integration.test.ts +2 -2
- package/src/api/api-constants.ts +10 -0
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +17 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +3 -3
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/patch.ts +9 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/__tests__/write-failures.test.ts +3 -3
- package/src/errors/kumiko-error.ts +11 -11
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +26 -42
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +8 -6
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/schema-cli.ts +9 -4
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +30 -14
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/test-stack.ts +3 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +100 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.221.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"./package.json": "./package.json"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
197
|
+
"@cosmicdrift/kumiko-types": "0.221.0",
|
|
198
198
|
"bullmq": "^5.76.7",
|
|
199
199
|
"bun-types": "^1.3.13",
|
|
200
200
|
"hono": "^4.13.1",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"zod": "^4.4.3"
|
|
211
211
|
},
|
|
212
212
|
"devDependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.221.0",
|
|
214
214
|
"bun-types": "^1.3.13",
|
|
215
215
|
"pino-pretty": "^13.1.3"
|
|
216
216
|
},
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { Temporal } from "temporal-polyfill";
|
|
7
8
|
import { defineUnmanagedTable } from "../db/entity-table-meta";
|
|
8
9
|
import { asRawClient, insertOne, selectMany } from "../db/query";
|
|
9
10
|
import { defineFeature } from "../engine";
|
|
@@ -49,7 +50,7 @@ describe("r.storeTable — DB roundtrip via setupTestStack", () => {
|
|
|
49
50
|
|
|
50
51
|
expect(rows).toHaveLength(1);
|
|
51
52
|
expect(rows[0]?.payload).toBe(payload);
|
|
52
|
-
expect(
|
|
53
|
+
expect(rows[0]?.receivedAt).toBeInstanceOf(Temporal.Instant);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
test("registry exposes the store table with its reason and featureName", () => {
|
|
@@ -243,35 +243,46 @@ const LEGACY_IMPORT_FIXTURE = [
|
|
|
243
243
|
"",
|
|
244
244
|
].join("\n");
|
|
245
245
|
|
|
246
|
+
// A dedicated fixture — not the real repo's scripts/codemod/ — so the
|
|
247
|
+
// non-.ts and doesn't-exist cases below are isolated from whatever files
|
|
248
|
+
// happen to exist in the real tree. README.md is created for real so a
|
|
249
|
+
// non-.ts rejection is provably caused by the extension check, not by the
|
|
250
|
+
// existsSync guard rejecting a file that never existed.
|
|
251
|
+
function makeCodemodScriptsFixture(): string {
|
|
252
|
+
return tmp({ "scripts/codemod/README.md": "# Codemods\n" });
|
|
253
|
+
}
|
|
254
|
+
|
|
246
255
|
describe("resolveCodemodScript", () => {
|
|
247
256
|
test("resolves a real script under scripts/codemod/", () => {
|
|
248
|
-
const resolved = resolveCodemodScript(
|
|
257
|
+
const resolved = resolveCodemodScript(REAL_REPO_ROOT, REAL_CODEMOD);
|
|
249
258
|
expect(resolved).toBe(join(REAL_FRAMEWORK_SRC, REAL_CODEMOD));
|
|
250
259
|
});
|
|
251
260
|
|
|
252
261
|
test("rejects an absolute path", () => {
|
|
253
|
-
expect(resolveCodemodScript(
|
|
262
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, "/etc/passwd.ts")).toBeNull();
|
|
254
263
|
});
|
|
255
264
|
|
|
256
265
|
test("rejects path traversal that escapes scripts/codemod/", () => {
|
|
257
266
|
expect(
|
|
258
|
-
resolveCodemodScript(
|
|
267
|
+
resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/../../package.json.ts"),
|
|
259
268
|
).toBeNull();
|
|
260
|
-
expect(resolveCodemodScript(
|
|
269
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, "../outside/x.ts")).toBeNull();
|
|
261
270
|
});
|
|
262
271
|
|
|
263
272
|
test("rejects a non-.ts file", () => {
|
|
264
|
-
|
|
273
|
+
const root = makeCodemodScriptsFixture();
|
|
274
|
+
|
|
275
|
+
expect(resolveCodemodScript(root, "scripts/codemod/README.md")).toBeNull();
|
|
265
276
|
});
|
|
266
277
|
|
|
267
278
|
test("rejects a script that doesn't exist", () => {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
).toBeNull();
|
|
279
|
+
const root = makeCodemodScriptsFixture();
|
|
280
|
+
|
|
281
|
+
expect(resolveCodemodScript(root, "scripts/codemod/does-not-exist.ts")).toBeNull();
|
|
271
282
|
});
|
|
272
283
|
|
|
273
284
|
test("rejects an undefined codemod field", () => {
|
|
274
|
-
expect(resolveCodemodScript(
|
|
285
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, undefined)).toBeNull();
|
|
275
286
|
});
|
|
276
287
|
|
|
277
288
|
test("findCodemodScriptsRoot resolves through a hoisted node_modules symlink", () => {
|
|
@@ -283,7 +294,7 @@ describe("resolveCodemodScript", () => {
|
|
|
283
294
|
const root = findCodemodScriptsRoot(join(cwd, "apps/web"));
|
|
284
295
|
expect(root).toBe(join(nmPkgDir, "src"));
|
|
285
296
|
|
|
286
|
-
const resolved = resolveCodemodScript(
|
|
297
|
+
const resolved = resolveCodemodScript(join(cwd, "apps/web"), REAL_CODEMOD);
|
|
287
298
|
expect(resolved).toBe(join(nmPkgDir, "src", REAL_CODEMOD));
|
|
288
299
|
});
|
|
289
300
|
});
|
|
@@ -365,7 +376,27 @@ describe("upgrade command — --apply", () => {
|
|
|
365
376
|
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
366
377
|
});
|
|
367
378
|
|
|
368
|
-
test("
|
|
379
|
+
test("stops and writes no marker when the codemod script exits non-zero", async () => {
|
|
380
|
+
const cwd = tmp({
|
|
381
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(
|
|
382
|
+
"scripts/codemod/always-fail.ts",
|
|
383
|
+
),
|
|
384
|
+
});
|
|
385
|
+
const failingRepoRoot = tmp({
|
|
386
|
+
"packages/framework/src/scripts/codemod/always-fail.ts": "process.exit(1);\n",
|
|
387
|
+
});
|
|
388
|
+
const spy = makeSpyOutput();
|
|
389
|
+
|
|
390
|
+
const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
|
|
391
|
+
repoRoot: failingRepoRoot,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(exit).toBe(1);
|
|
395
|
+
expect(spy.errs.join("\n")).toContain("scripts/codemod/always-fail.ts failed");
|
|
396
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("breaking changes without a codemod field are reported as manual; marker still written", async () => {
|
|
369
400
|
const cwd = tmp({
|
|
370
401
|
"packages/framework/src/changes.json": breakingEntryWithCodemod(undefined),
|
|
371
402
|
});
|
|
@@ -377,7 +408,10 @@ describe("upgrade command — --apply", () => {
|
|
|
377
408
|
|
|
378
409
|
expect(exit).toBe(0);
|
|
379
410
|
expect(spy.logs.join("\n")).toContain("no codemod, manual migration required");
|
|
380
|
-
|
|
411
|
+
// Marker must be written so guard-upgrade-state is not stuck when only
|
|
412
|
+
// manuals/improvements remain (fw#2299 / #2308). Manuals stay pending via
|
|
413
|
+
// markerVersionForPending not advancing onto them when mixed with later work.
|
|
414
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(true);
|
|
381
415
|
});
|
|
382
416
|
|
|
383
417
|
test("nothing pending: reports up to date, still bootstraps the marker", async () => {
|
|
@@ -401,6 +435,41 @@ describe("upgrade command — --apply", () => {
|
|
|
401
435
|
expect(typeof marker.appliedAt).toBe("string");
|
|
402
436
|
});
|
|
403
437
|
|
|
438
|
+
test("nothing pending + --from: marker records installed version, not the filter", async () => {
|
|
439
|
+
const cwd = tmp({
|
|
440
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
441
|
+
"packages/bundled-features/package.json": JSON.stringify({ version: "0.190.0" }),
|
|
442
|
+
});
|
|
443
|
+
const spy = makeSpyOutput();
|
|
444
|
+
|
|
445
|
+
const exit = await runUpgradeCli(["--from", "0.999.0", "--apply"], cwd, spy.out, {
|
|
446
|
+
repoRoot: REAL_REPO_ROOT,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(exit).toBe(0);
|
|
450
|
+
const marker = JSON.parse(readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"));
|
|
451
|
+
expect(marker.version).toBe("0.190.0");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("--dir that is not a directory exits 1 without writing a marker", async () => {
|
|
455
|
+
const cwd = tmp({
|
|
456
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
457
|
+
"not-a-dir.txt": "x",
|
|
458
|
+
});
|
|
459
|
+
const spy = makeSpyOutput();
|
|
460
|
+
|
|
461
|
+
const exit = await runUpgradeCli(
|
|
462
|
+
["--from", "0.165.0", "--apply", "--dir", join(cwd, "not-a-dir.txt")],
|
|
463
|
+
cwd,
|
|
464
|
+
spy.out,
|
|
465
|
+
{ repoRoot: REAL_REPO_ROOT },
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(exit).toBe(1);
|
|
469
|
+
expect(spy.errs.join("\n")).toContain("not a directory");
|
|
470
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
|
|
404
473
|
test("nothing pending + --dry-run: reports up to date, writes no marker", async () => {
|
|
405
474
|
const cwd = tmp({
|
|
406
475
|
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
@@ -85,7 +85,7 @@ describe("ctx.locale falls back to the app's boot-configured defaultLocale", ()
|
|
|
85
85
|
beforeAll(async () => {
|
|
86
86
|
stack = await setupTestStack({
|
|
87
87
|
features: [localeProbe],
|
|
88
|
-
|
|
88
|
+
defaultLocale: "ja",
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -108,7 +108,7 @@ describe("ctx.locale falls back to SessionUser.locale ahead of the boot default"
|
|
|
108
108
|
beforeAll(async () => {
|
|
109
109
|
stack = await setupTestStack({
|
|
110
110
|
features: [localeProbe],
|
|
111
|
-
|
|
111
|
+
defaultLocale: "ja",
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
|
package/src/api/api-constants.ts
CHANGED
|
@@ -125,4 +125,14 @@ export const TENANT_COOKIE_NAME = "kumiko_tenant";
|
|
|
125
125
|
// request-locale.ts and dispatch-shared.ts's ctx.locale resolution.
|
|
126
126
|
export const LOCALE_HEADER_NAME = "X-Locale";
|
|
127
127
|
|
|
128
|
+
// Internal-only signal set by buildServer's app.notFound() to mark "no
|
|
129
|
+
// route matched" 404s so tryHonoFirst (server-runtime) can tell them apart
|
|
130
|
+
// from a matched route's own deliberate 404 (e.g. default-deny reads) —
|
|
131
|
+
// see kumiko-framework#2435. Never reaches a client: tryHonoFirst and every
|
|
132
|
+
// direct app.fetch() passthrough strip it before the response leaves the
|
|
133
|
+
// process, since its presence would otherwise let a caller distinguish
|
|
134
|
+
// "unregistered path" from "registered path, access denied" — a leak the
|
|
135
|
+
// denying routes deliberately collapse into one status code.
|
|
136
|
+
export const NO_ROUTE_MATCH_HEADER_NAME = "X-Kumiko-No-Route-Match";
|
|
137
|
+
|
|
128
138
|
export type Route = (typeof Routes)[keyof typeof Routes];
|
package/src/api/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { SetTenantCookieOptions } from "./anonymous-cookie";
|
|
2
2
|
export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
|
|
3
|
+
export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants";
|
|
3
4
|
export type {
|
|
4
5
|
AnonymousAccessConfig,
|
|
5
6
|
AnonymousAccessResolved,
|
package/src/api/server.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
} from "../rate-limit";
|
|
50
50
|
import type { SearchAdapter } from "../search/types";
|
|
51
51
|
import { assertUnreachable, generateId } from "../utils";
|
|
52
|
-
import { PUBLIC_API_PATHS } from "./api-constants";
|
|
52
|
+
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
|
|
53
53
|
import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
|
|
54
54
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
55
55
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
@@ -836,6 +836,22 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
836
836
|
// Default-Handler aus BUILD_VERSION/BUILD_TIME-env-vars.
|
|
837
837
|
registerVersionRoute(app);
|
|
838
838
|
|
|
839
|
+
// Marks "no route matched this path" so tryHonoFirst (server-runtime)
|
|
840
|
+
// can tell it apart from a matched route's OWN deliberate 404 (e.g.
|
|
841
|
+
// default-deny reads like file-derivatives' public-variant route) —
|
|
842
|
+
// without this, tryHonoFirst's only signal was the status code, and a
|
|
843
|
+
// matched route answering 404 got silently rewritten to the SPA shell
|
|
844
|
+
// with status 200 (kumiko-framework#2435). Must be the LAST thing wired
|
|
845
|
+
// on `app`: this only fires when the router found no handler at all, so
|
|
846
|
+
// a route's own `c.text(..., 404)` never passes through here. A handler
|
|
847
|
+
// that calls `c.notFound()` instead of building its own Response DOES
|
|
848
|
+
// route through this same handler and stays indistinguishable from a
|
|
849
|
+
// routing miss — pre-existing Hono semantics, not something this fix
|
|
850
|
+
// changes; deliberate 404s must build their own Response.
|
|
851
|
+
// The header is an internal signal only — tryHonoFirst and every direct
|
|
852
|
+
// app.fetch() passthrough strip it before a response reaches a client.
|
|
853
|
+
app.notFound((c) => c.text("Not Found", 404, { [NO_ROUTE_MATCH_HEADER_NAME]: "1" }));
|
|
854
|
+
|
|
839
855
|
return {
|
|
840
856
|
app,
|
|
841
857
|
jwt,
|
|
@@ -28,6 +28,7 @@ describe("coerceRow — date → Temporal.PlainDate", () => {
|
|
|
28
28
|
// (2026-03-15T00:00:00.000Z) regardless of process TZ.
|
|
29
29
|
const row = { published_at: new Date(Date.UTC(2026, 2, 15)) };
|
|
30
30
|
const result = coerceRow(row, dateTableInfo());
|
|
31
|
+
expect(result.published_at).toBeInstanceOf(Temporal.PlainDate);
|
|
31
32
|
expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-03-15");
|
|
32
33
|
});
|
|
33
34
|
|
|
@@ -50,6 +51,7 @@ describe("coerceRow — date → Temporal.PlainDate", () => {
|
|
|
50
51
|
test("coerces a plain 'yyyy-mm-dd' driver string", () => {
|
|
51
52
|
const row = { published_at: "2026-12-01" };
|
|
52
53
|
const result = coerceRow(row, dateTableInfo());
|
|
54
|
+
expect(result.published_at).toBeInstanceOf(Temporal.PlainDate);
|
|
53
55
|
expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-12-01");
|
|
54
56
|
});
|
|
55
57
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// coerceRow — proving the coercion no longer depends on the global at all.
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import
|
|
11
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
12
|
import { coerceRow, type TableInfo } from "../query";
|
|
13
13
|
|
|
14
14
|
function timestamptzTableInfo(): TableInfo {
|
|
@@ -29,6 +29,7 @@ describe("coerceRow — timestamptz → Temporal.Instant", () => {
|
|
|
29
29
|
try {
|
|
30
30
|
const row = { updated_at: new Date("2026-04-18T10:00:00Z") };
|
|
31
31
|
const result = coerceRow(row, timestamptzTableInfo());
|
|
32
|
+
expect(result.updated_at).toBeInstanceOf(Temporal.Instant);
|
|
32
33
|
expect((result.updated_at as unknown as Temporal.Instant).toString()).toBe(
|
|
33
34
|
"2026-04-18T10:00:00Z",
|
|
34
35
|
);
|
package/src/bun-db/index.ts
CHANGED
package/src/bun-db/query.ts
CHANGED
|
@@ -25,8 +25,8 @@ import type {
|
|
|
25
25
|
WhereObject,
|
|
26
26
|
WhereOperator,
|
|
27
27
|
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
28
|
-
// Static import
|
|
29
|
-
//
|
|
28
|
+
// Static polyfill import (not getTemporal()): coercion results stay on one
|
|
29
|
+
// Temporal implementation repo-wide, even when Bun exposes a global (#1480).
|
|
30
30
|
import { Temporal } from "temporal-polyfill";
|
|
31
31
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
32
32
|
import { SQL_EXPR_BRAND } from "../db/dialect";
|
|
@@ -46,6 +46,7 @@ function snakeToCamel(key: string): string {
|
|
|
46
46
|
return envCamelCase(key);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
|
|
49
50
|
import type { BunDbRunner } from "./connection";
|
|
50
51
|
|
|
51
52
|
// Drizzle-pgTable-Inspection via raw Symbol-access (kein drizzle-orm import).
|
|
@@ -155,16 +156,18 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
|
|
|
155
156
|
// executed yet.
|
|
156
157
|
export async function runInSavepointIfSupported<T>(
|
|
157
158
|
db: unknown,
|
|
158
|
-
fn: (sp:
|
|
159
|
+
fn: (sp: DbRunner) => Promise<T>,
|
|
159
160
|
): Promise<T> {
|
|
160
161
|
const raw = asRawClient(db) as unknown as {
|
|
161
162
|
savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
|
|
162
163
|
};
|
|
163
|
-
|
|
164
|
+
// @cast-boundary driver savepoint handle → DbRunner at the single seam
|
|
165
|
+
const asRunner = (sp: unknown): DbRunner => sp as DbRunner;
|
|
166
|
+
if (typeof raw.savepoint !== "function") return fn(asRunner(db));
|
|
164
167
|
try {
|
|
165
|
-
return await raw.savepoint(fn);
|
|
168
|
+
return await raw.savepoint((sp) => fn(asRunner(sp)));
|
|
166
169
|
} catch (e) {
|
|
167
|
-
if (extractPgError(e)?.code === "25P01") return fn(db);
|
|
170
|
+
if (extractPgError(e)?.code === "25P01") return fn(asRunner(db));
|
|
168
171
|
throw e;
|
|
169
172
|
}
|
|
170
173
|
}
|
|
@@ -328,11 +331,11 @@ function quoteIdent(name: string): string {
|
|
|
328
331
|
// Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
|
|
329
332
|
// params — convert to ISO string when the column pgType is timestamptz.
|
|
330
333
|
|
|
331
|
-
function isTemporalInstant(v: unknown):
|
|
334
|
+
function isTemporalInstant(v: unknown): v is Temporal.Instant {
|
|
332
335
|
return (
|
|
333
336
|
typeof v === "object" &&
|
|
334
337
|
v !== null &&
|
|
335
|
-
|
|
338
|
+
(v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.Instant"
|
|
336
339
|
);
|
|
337
340
|
}
|
|
338
341
|
|
|
@@ -377,12 +380,21 @@ function instantFromDriver(value: unknown): Temporal.Instant | null {
|
|
|
377
380
|
return null;
|
|
378
381
|
}
|
|
379
382
|
|
|
380
|
-
function isTemporalPlainDate(v: unknown):
|
|
383
|
+
function isTemporalPlainDate(v: unknown): v is Temporal.PlainDate {
|
|
381
384
|
return (
|
|
382
385
|
typeof v === "object" &&
|
|
383
386
|
v !== null &&
|
|
384
|
-
|
|
385
|
-
|
|
387
|
+
(v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.PlainDate"
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isTemporalPlainDateTimeLike(v: unknown): v is { toPlainDate(): Temporal.PlainDate } {
|
|
392
|
+
return (
|
|
393
|
+
typeof v === "object" &&
|
|
394
|
+
v !== null &&
|
|
395
|
+
((v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.PlainDateTime" ||
|
|
396
|
+
(v as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "Temporal.ZonedDateTime") &&
|
|
397
|
+
typeof (v as { toPlainDate?: unknown }).toPlainDate === "function"
|
|
386
398
|
);
|
|
387
399
|
}
|
|
388
400
|
|
|
@@ -397,7 +409,7 @@ function isTemporalPlainDate(v: unknown): boolean {
|
|
|
397
409
|
// `.getTime()`), just anchored to a fixed zone instead of process-local.
|
|
398
410
|
function plainDateFromDriver(value: unknown): Temporal.PlainDate | null {
|
|
399
411
|
if (value === null || value === undefined) return null;
|
|
400
|
-
if (isTemporalPlainDate(value)) return value
|
|
412
|
+
if (isTemporalPlainDate(value)) return value;
|
|
401
413
|
if (typeof value === "string") {
|
|
402
414
|
const isoDay = /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0];
|
|
403
415
|
if (isoDay === undefined) return null;
|
|
@@ -520,10 +532,13 @@ function isSqlExpression(v: unknown): v is { kind: "sql-expr"; text: string } {
|
|
|
520
532
|
// driver can't serialize.
|
|
521
533
|
function prepareDateValue(value: unknown): PreparedValue | undefined {
|
|
522
534
|
if (isTemporalPlainDate(value)) {
|
|
523
|
-
return { kind: "param", sql: "", bound:
|
|
535
|
+
return { kind: "param", sql: "", bound: value.toString() };
|
|
536
|
+
}
|
|
537
|
+
if (isTemporalPlainDateTimeLike(value)) {
|
|
538
|
+
return { kind: "param", sql: "", bound: value.toPlainDate().toString() };
|
|
524
539
|
}
|
|
525
540
|
if (isTemporalInstant(value)) {
|
|
526
|
-
const day =
|
|
541
|
+
const day = value.toZonedDateTimeISO("UTC").toPlainDate();
|
|
527
542
|
return { kind: "param", sql: "", bound: day.toString() };
|
|
528
543
|
}
|
|
529
544
|
return undefined;
|
|
@@ -688,6 +703,7 @@ function isClosedConnectionError(err: unknown): boolean {
|
|
|
688
703
|
// Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
|
|
689
704
|
// db/queries/*.ts) can opt into the same #1163 retry instead of calling
|
|
690
705
|
// asRawClient(db).unsafe(...) directly and losing it.
|
|
706
|
+
// READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
|
|
691
707
|
export async function unsafeReadRetrying<TRow>(
|
|
692
708
|
db: AnyDb,
|
|
693
709
|
sqlText: string,
|
package/src/crypto/index.ts
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
2
|
+
|
|
3
|
+
type AnyField = EntityDefinition["fields"][string];
|
|
4
|
+
|
|
5
|
+
/** True when the field's own row id is the PII subject (`pii: true`). */
|
|
6
|
+
export function isSelfPiiField(field: AnyField): boolean {
|
|
7
|
+
return "pii" in field && field.pii === true;
|
|
8
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
2
2
|
import type { TenantId } from "../engine/types/identifiers";
|
|
3
|
+
import { isSelfPiiField } from "./is-self-pii-field";
|
|
3
4
|
import type { SubjectId } from "./kms-adapter";
|
|
4
5
|
|
|
5
6
|
// Thrown when a field IS pii-annotated but the row can't name its subject —
|
|
@@ -63,7 +64,7 @@ export function resolveSubjectForField(
|
|
|
63
64
|
return { kind: "tenant", tenantId };
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
if (
|
|
67
|
+
if (isSelfPiiField(field)) {
|
|
67
68
|
// pii: true = the entity itself is the subject (user.email belongs to
|
|
68
69
|
// that user row). Serial ids are stringified — subject keys are text.
|
|
69
70
|
const id = row["id"];
|
|
@@ -85,7 +86,7 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
|
|
|
85
86
|
([, field]) =>
|
|
86
87
|
("userOwned" in field && field.userOwned !== undefined) ||
|
|
87
88
|
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
88
|
-
(
|
|
89
|
+
isSelfPiiField(field),
|
|
89
90
|
)
|
|
90
91
|
.map(([name]) => name);
|
|
91
92
|
}
|
|
@@ -97,7 +98,7 @@ export function collectSearchableSubjectFields(entity: EntityDefinition): readon
|
|
|
97
98
|
const subject =
|
|
98
99
|
("userOwned" in field && field.userOwned !== undefined) ||
|
|
99
100
|
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
100
|
-
(
|
|
101
|
+
isSelfPiiField(field);
|
|
101
102
|
if (!subject) return false;
|
|
102
103
|
if ("sensitive" in field && field.sensitive === true) return false;
|
|
103
104
|
return "searchable" in field && field.searchable === true;
|
|
@@ -506,7 +506,9 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
|
|
|
506
506
|
expect(res.rows).toHaveLength(0);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
-
test("system-mode list: search uses
|
|
509
|
+
test("system-mode list: search still uses session tenantId (streamTenantFor)", async () => {
|
|
510
|
+
// Non-systemStream entities are indexed under event.tenantId (= session),
|
|
511
|
+
// even when listed via r.systemScope() — db.mode must not redirect search.
|
|
510
512
|
const systemTdb = createTenantDb(testDb.db, admin.tenantId, "system");
|
|
511
513
|
let searchedTenant: string | undefined;
|
|
512
514
|
const mockAdapter = {
|
|
@@ -523,7 +525,7 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
|
|
|
523
525
|
await exec.list({ limit: 50, search: "x" }, admin, systemTdb, {
|
|
524
526
|
searchAdapter: mockAdapter,
|
|
525
527
|
});
|
|
526
|
-
expect(searchedTenant).toBe(
|
|
528
|
+
expect(searchedTenant).toBe(admin.tenantId);
|
|
527
529
|
});
|
|
528
530
|
|
|
529
531
|
test("tenant-mode list: search still uses session tenantId", async () => {
|
|
@@ -544,6 +546,33 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
|
|
|
544
546
|
});
|
|
545
547
|
expect(searchedTenant).toBe(admin.tenantId);
|
|
546
548
|
});
|
|
549
|
+
|
|
550
|
+
test("tenant-mode list: systemStream entity searches SYSTEM_TENANT_ID", async () => {
|
|
551
|
+
const sysEntity = createEntity({
|
|
552
|
+
table: "read_pager_sys",
|
|
553
|
+
systemStream: true,
|
|
554
|
+
fields: { title: createTextField({ required: true }) },
|
|
555
|
+
});
|
|
556
|
+
const sysTable = buildEntityTable("pagerSys", sysEntity);
|
|
557
|
+
await unsafeCreateEntityTable(testDb.db, sysEntity, "pagerSys");
|
|
558
|
+
const sysExec = createEventStoreExecutor(sysTable, sysEntity, { entityName: "pagerSys" });
|
|
559
|
+
let searchedTenant: string | undefined;
|
|
560
|
+
const mockAdapter = {
|
|
561
|
+
configure: async () => {},
|
|
562
|
+
index: async () => {},
|
|
563
|
+
indexBatch: async () => {},
|
|
564
|
+
remove: async () => {},
|
|
565
|
+
search: async (tenantId: string) => {
|
|
566
|
+
searchedTenant = tenantId;
|
|
567
|
+
return [];
|
|
568
|
+
},
|
|
569
|
+
reset: async () => {},
|
|
570
|
+
} as never;
|
|
571
|
+
await sysExec.list({ limit: 50, search: "x" }, admin, tdb, {
|
|
572
|
+
searchAdapter: mockAdapter,
|
|
573
|
+
});
|
|
574
|
+
expect(searchedTenant).toBe(SYSTEM_TENANT_ID);
|
|
575
|
+
});
|
|
547
576
|
});
|
|
548
577
|
|
|
549
578
|
describe("event-store-executor.list — keyset cursor mit custom sort (#2265)", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { EntityTableMeta } from "../entity-table-meta";
|
|
3
3
|
import {
|
|
4
|
+
assertValidMigrationName,
|
|
4
5
|
diffSnapshots,
|
|
5
6
|
generateMigration,
|
|
6
7
|
renderMigrationSql,
|
|
@@ -197,3 +198,14 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
|
|
|
197
198
|
expect(sql).toContain("new NOT NULL column(s) without default: envelope");
|
|
198
199
|
});
|
|
199
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("assertValidMigrationName", () => {
|
|
203
|
+
test("accepts alphanumeric hyphenated names", () => {
|
|
204
|
+
expect(() => assertValidMigrationName("add-user-table")).not.toThrow();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("rejects leading hyphen and oversized names", () => {
|
|
208
|
+
expect(() => assertValidMigrationName("-foo")).toThrow(/Invalid migration name/);
|
|
209
|
+
expect(() => assertValidMigrationName(`a${"x".repeat(64)}`)).toThrow(/Invalid migration name/);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
11
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
13
|
import { selectMany } from "../../db/query";
|
|
13
14
|
import { buildEntityTable } from "../../db/table-builder";
|
|
14
15
|
import { createEntity, createTextField } from "../../engine";
|
|
@@ -61,6 +62,7 @@ describe("instant() customType is forgiving with ISO strings", () => {
|
|
|
61
62
|
expect(rows).toHaveLength(1);
|
|
62
63
|
const insertedAt = rows[0]?.["insertedAt"];
|
|
63
64
|
expect(insertedAt).toBeDefined();
|
|
65
|
+
expect(insertedAt).toBeInstanceOf(Temporal.Instant);
|
|
64
66
|
expect((insertedAt as Temporal.Instant).toString()).toBe(isoString);
|
|
65
67
|
});
|
|
66
68
|
});
|
|
@@ -314,6 +314,7 @@ describe("date field — real DATE column, no TZ dependence (kumiko-framework#19
|
|
|
314
314
|
const rows = await selectMany(testDb.db, table);
|
|
315
315
|
expect(rows).toHaveLength(1);
|
|
316
316
|
const periodFrom = (rows[0] as { periodFrom: unknown }).periodFrom;
|
|
317
|
+
expect(periodFrom).toBeInstanceOf(Temporal.PlainDate);
|
|
317
318
|
expect((periodFrom as Temporal.PlainDate).toString()).toBe("2026-03-15");
|
|
318
319
|
});
|
|
319
320
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
15
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
16
|
+
import { Temporal } from "temporal-polyfill";
|
|
16
17
|
import { selectMany } from "../../db/query";
|
|
17
18
|
import { buildEntityTable } from "../../db/table-builder";
|
|
18
19
|
import { createEntity, createTextField } from "../../engine";
|
|
@@ -52,6 +53,7 @@ describe("entity with a `source` field — create-path is shadow-proof", () => {
|
|
|
52
53
|
expect(rows[0]?.["source"]).toBe("import");
|
|
53
54
|
const insertedAt = rows[0]?.["insertedAt"];
|
|
54
55
|
expect(insertedAt).toBeDefined();
|
|
56
|
+
expect(insertedAt).toBeInstanceOf(Temporal.Instant);
|
|
55
57
|
expect((insertedAt as Temporal.Instant).toString()).toBe("2026-01-15T12:00:00Z");
|
|
56
58
|
});
|
|
57
59
|
});
|