@cosmicdrift/kumiko-framework 0.220.1 → 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/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 +22 -38
- 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/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/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +100 -14
|
@@ -90,6 +90,18 @@ describe("distributed lock", () => {
|
|
|
90
90
|
expect(await lock.acquire("test-lock-7")).not.toBeNull();
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test("renew with ttlSeconds 0 does not drop the lock", async () => {
|
|
94
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
95
|
+
const token = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
|
|
96
|
+
if (!token) throw new Error("expected token");
|
|
97
|
+
const renewed = await lock.renew("test-lock-ttl0", token, 0);
|
|
98
|
+
expect(renewed).toBe(false);
|
|
99
|
+
// Original lock still held — a peer acquire must fail.
|
|
100
|
+
const peer = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
|
|
101
|
+
expect(peer).toBeNull();
|
|
102
|
+
await lock.release("test-lock-ttl0", token);
|
|
103
|
+
});
|
|
104
|
+
|
|
93
105
|
test("renew on an expired/absent key fails", async () => {
|
|
94
106
|
const lock = createDistributedLock(testRedis.redis);
|
|
95
107
|
const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);
|
|
@@ -1,36 +1,18 @@
|
|
|
1
1
|
// E.4 — PG LISTEN/NOTIFY wake-up. Without this, delivery latency is
|
|
2
2
|
// bounded below by pollIntervalMs. With LISTEN, event-store.append fires
|
|
3
|
-
// `pg_notify` on commit and any subscribed dispatcher wakes immediately
|
|
4
|
-
// latency becomes TCP round-trip, typically sub-millisecond on localhost.
|
|
3
|
+
// `pg_notify` on commit and any subscribed dispatcher wakes immediately.
|
|
5
4
|
//
|
|
6
|
-
// The polling timer stays
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// 2. The dispatcher starts cleanly when pgClient is wired and stops
|
|
11
|
-
// without leaking the LISTEN connection, and still wakes on NOTIFY
|
|
12
|
-
// after a restart cycle.
|
|
13
|
-
//
|
|
14
|
-
// #2042: these used to assert an absolute millisecond latency bound
|
|
15
|
-
// (`< 40`, later `< 100`) against the test-stack's default 50ms polling
|
|
16
|
-
// timer. On a shared CI runner a stalled event loop can push even a
|
|
17
|
-
// working LISTEN's delivery past 100ms — measured up to 154ms — so no
|
|
18
|
-
// millisecond bound both clears runner noise and stays under a 50ms
|
|
19
|
-
// timer. Fix: push the polling timer out to 60s for this stack
|
|
20
|
-
// (`eventDispatcherPollIntervalMs`) and assert delivery happens at all
|
|
21
|
-
// inside a 5s window. If LISTEN is dead, nothing arrives before the 5s
|
|
22
|
-
// deadline — a 12x margin under the 60s timer that no runner stall gets
|
|
23
|
-
// anywhere near. If LISTEN works, delivery is near-instant regardless of
|
|
24
|
-
// runner load. Verified by temporarily forcing pgClient to undefined in
|
|
25
|
-
// test-stack.ts: both tests then fail at toHaveLength(1) with 0 received
|
|
26
|
-
// after ~5s, confirming the assertion still discriminates.
|
|
5
|
+
// The polling timer stays as a safety net. These tests pin NOTIFY wake
|
|
6
|
+
// without waiting for the timer, and that start/stop still leaves LISTEN
|
|
7
|
+
// working. #2042: poll interval is 60s here; assert delivery inside 5s —
|
|
8
|
+
// if LISTEN is dead the timer cannot rescue the assertion.
|
|
27
9
|
|
|
28
10
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
29
11
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
30
12
|
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
31
13
|
import { defineFeature } from "../../engine";
|
|
32
14
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
33
|
-
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
15
|
+
import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
|
|
34
16
|
|
|
35
17
|
// --- Fixture ---
|
|
36
18
|
|
|
@@ -85,21 +67,22 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
|
|
|
85
67
|
try {
|
|
86
68
|
await executor.create({ name: "latency-test" }, admin, tdb);
|
|
87
69
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
70
|
+
await waitFor(
|
|
71
|
+
() => {
|
|
72
|
+
expect(deliveryCount).toBe(1);
|
|
73
|
+
},
|
|
74
|
+
{ delays: [100, 500, 1000, 3500] },
|
|
75
|
+
);
|
|
93
76
|
} finally {
|
|
94
77
|
await stack.eventDispatcher?.stop();
|
|
95
78
|
}
|
|
96
79
|
});
|
|
97
80
|
|
|
98
81
|
test("dispatcher start/stop cycle with LISTEN attached still delivers after restart", async () => {
|
|
99
|
-
// Repeated start/stop must not leak connections or break LISTEN.
|
|
100
|
-
//
|
|
101
|
-
// unlisten handle was mishandled, the subscription
|
|
102
|
-
//
|
|
82
|
+
// Repeated start/stop must not leak connections or break LISTEN.
|
|
83
|
+
// Two start/stop cycles, then a third start() that must still wake on
|
|
84
|
+
// NOTIFY — if the unlisten handle was mishandled, the subscription
|
|
85
|
+
// would be stale or double-registered.
|
|
103
86
|
for (let i = 0; i < 2; i++) {
|
|
104
87
|
await stack.eventDispatcher?.start();
|
|
105
88
|
await stack.eventDispatcher?.stop();
|
|
@@ -110,11 +93,12 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
|
|
|
110
93
|
try {
|
|
111
94
|
await executor.create({ name: "restart-probe" }, admin, tdb);
|
|
112
95
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
96
|
+
await waitFor(
|
|
97
|
+
() => {
|
|
98
|
+
expect(deliveryCount).toBe(1);
|
|
99
|
+
},
|
|
100
|
+
{ delays: [100, 500, 1000, 3500] },
|
|
101
|
+
);
|
|
118
102
|
} finally {
|
|
119
103
|
await stack.eventDispatcher?.stop();
|
|
120
104
|
}
|
|
@@ -50,6 +50,9 @@ export function createDistributedLock(
|
|
|
50
50
|
},
|
|
51
51
|
|
|
52
52
|
async renew(key, token, ttlSeconds) {
|
|
53
|
+
// EXPIRE with 0/negative deletes the key but still returns 1 — reject
|
|
54
|
+
// before we claim ownership while releasing the lock.
|
|
55
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) return false;
|
|
53
56
|
const result = (await redis.eval(
|
|
54
57
|
renewScript,
|
|
55
58
|
1,
|
package/src/schema-cli.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { join, resolve as resolvePath } from "node:path";
|
|
13
13
|
import {
|
|
14
|
+
assertValidMigrationName,
|
|
14
15
|
baselineMigrations,
|
|
15
16
|
createDbConnection,
|
|
16
17
|
type DbConnection,
|
|
@@ -151,10 +152,14 @@ export async function runSchemaCli(
|
|
|
151
152
|
out.err(" Usage: schema generate <name>");
|
|
152
153
|
return 1;
|
|
153
154
|
}
|
|
154
|
-
// name lands unescaped in `${seq}_${name}.sql` (generateMigration) —
|
|
155
|
-
// allowlist blocks flag-like names
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
// name lands unescaped in `${seq}_${name}.sql` (generateMigration) —
|
|
156
|
+
// shared allowlist blocks flag-like names, path traversal, and
|
|
157
|
+
// ENAMETOOLONG-length names.
|
|
158
|
+
try {
|
|
159
|
+
assertValidMigrationName(name);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
162
|
+
out.err(` ${msg}`);
|
|
158
163
|
out.err(" Usage: schema generate <name>");
|
|
159
164
|
return 1;
|
|
160
165
|
}
|
|
@@ -8,47 +8,55 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Usage: bun scripts/codemod/crypto-shredding-testing-move.ts <targetDir> [--dry-run]
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { Project } from "ts-morph";
|
|
11
|
+
import { type Dirent, readdirSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { Project, type SourceFile } from "ts-morph";
|
|
14
14
|
|
|
15
15
|
const OLD_SPECIFIER = "@cosmicdrift/kumiko-framework/crypto";
|
|
16
16
|
const NEW_SPECIFIER = "@cosmicdrift/kumiko-framework/testing";
|
|
17
17
|
const MOVED_NAMES = new Set(["resetPiiSubjectKmsForTests", "resetBlindIndexKeyForTests"]);
|
|
18
18
|
|
|
19
|
+
const EXCLUDE_DIRS = new Set(["node_modules", "dist", "build"]);
|
|
20
|
+
|
|
19
21
|
function findTargetFiles(rootDir: string): string[] {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// Walk the tree and skip excluded dirs while descending — filtering the
|
|
23
|
+
// absolute path after a full Glob scan silently no-ops when the repo root
|
|
24
|
+
// itself contains "/build/" or "/node_modules/" (fw#2289).
|
|
22
25
|
const files: string[] = [];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const walk = (dir: string): void => {
|
|
27
|
+
let entries: Dirent[];
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
// skip: unreadable directory during walk — treat as empty.
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const ent of entries) {
|
|
35
|
+
if (ent.isDirectory()) {
|
|
36
|
+
if (EXCLUDE_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
|
|
37
|
+
walk(join(dir, ent.name));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (ent.isFile() && /\.(ts|tsx)$/.test(ent.name)) {
|
|
41
|
+
files.push(join(dir, ent.name));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(rootDir);
|
|
28
46
|
return files.sort();
|
|
29
47
|
}
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
skipFileDependencyResolution: true,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
let touchedFiles = 0;
|
|
43
|
-
let movedNames = 0;
|
|
44
|
-
|
|
45
|
-
for (const file of files) {
|
|
46
|
-
const sourceFile = project.addSourceFileAtPath(file);
|
|
47
|
-
const oldImport = sourceFile
|
|
48
|
-
.getImportDeclarations()
|
|
49
|
-
.find((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER);
|
|
50
|
-
if (!oldImport) continue;
|
|
49
|
+
/** Move matching value imports from crypto → testing. Returns count of names moved. */
|
|
50
|
+
function migrateFile(sourceFile: SourceFile): number {
|
|
51
|
+
// Value imports only — a pre-existing `import type { … } from testing`
|
|
52
|
+
// must not receive runtime helpers (they would be erased).
|
|
53
|
+
const oldImports = sourceFile
|
|
54
|
+
.getImportDeclarations()
|
|
55
|
+
.filter((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER && !d.isTypeOnly());
|
|
56
|
+
if (oldImports.length === 0) return 0;
|
|
51
57
|
|
|
58
|
+
let fileMoved = 0;
|
|
59
|
+
for (const oldImport of oldImports) {
|
|
52
60
|
const movedHere = oldImport.getNamedImports().filter((spec) => MOVED_NAMES.has(spec.getName()));
|
|
53
61
|
if (movedHere.length === 0) continue;
|
|
54
62
|
|
|
@@ -56,7 +64,7 @@ async function main(): Promise<void> {
|
|
|
56
64
|
|
|
57
65
|
const existingNewImport = sourceFile
|
|
58
66
|
.getImportDeclarations()
|
|
59
|
-
.find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER);
|
|
67
|
+
.find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER && !d.isTypeOnly());
|
|
60
68
|
if (existingNewImport) {
|
|
61
69
|
const already = new Set(existingNewImport.getNamedImports().map((s) => s.getName()));
|
|
62
70
|
for (const name of names) {
|
|
@@ -73,8 +81,32 @@ async function main(): Promise<void> {
|
|
|
73
81
|
!!oldImport.getNamespaceImport();
|
|
74
82
|
if (!remaining) oldImport.remove();
|
|
75
83
|
|
|
84
|
+
fileMoved += names.length;
|
|
85
|
+
}
|
|
86
|
+
return fileMoved;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function main(): Promise<void> {
|
|
90
|
+
const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
91
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
92
|
+
const rootDir = resolve(positional[0] ?? process.cwd());
|
|
93
|
+
|
|
94
|
+
const files = findTargetFiles(rootDir);
|
|
95
|
+
const project = new Project({
|
|
96
|
+
skipAddingFilesFromTsConfig: true,
|
|
97
|
+
skipFileDependencyResolution: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
let touchedFiles = 0;
|
|
101
|
+
let movedNames = 0;
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const sourceFile = project.addSourceFileAtPath(file);
|
|
105
|
+
const fileMoved = migrateFile(sourceFile);
|
|
106
|
+
if (fileMoved === 0) continue;
|
|
107
|
+
|
|
76
108
|
touchedFiles++;
|
|
77
|
-
movedNames +=
|
|
109
|
+
movedNames += fileMoved;
|
|
78
110
|
if (!dryRun) sourceFile.saveSync();
|
|
79
111
|
}
|
|
80
112
|
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
// that still carry the subject key in encrypted columns.
|
|
10
10
|
|
|
11
11
|
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
12
|
+
import { isSelfPiiField } from "../crypto/is-self-pii-field";
|
|
12
13
|
import type { SubjectId } from "../crypto/kms-adapter";
|
|
13
14
|
import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
|
|
14
15
|
import type { DbRunner } from "../db/connection";
|
|
15
16
|
import { resolveTableName } from "../db/entity-table-meta";
|
|
16
|
-
import {
|
|
17
|
+
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
17
18
|
import type { FeatureDefinition } from "../engine/types";
|
|
18
19
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
19
20
|
import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
@@ -46,7 +47,7 @@ function ownershipPredicates(
|
|
|
46
47
|
params.push(subject.userId);
|
|
47
48
|
parts.push(`${quoteIdent(col)} = $${n}`);
|
|
48
49
|
}
|
|
49
|
-
} else if (
|
|
50
|
+
} else if (isSelfPiiField(field) && selfIdN === undefined) {
|
|
50
51
|
selfIdN = nextParam();
|
|
51
52
|
params.push(subject.userId);
|
|
52
53
|
parts.push(`${quoteIdent("id")} = $${selfIdN}`);
|
|
@@ -78,7 +79,7 @@ async function collectMatchingRowsForEntity(
|
|
|
78
79
|
let offset = 0;
|
|
79
80
|
for (;;) {
|
|
80
81
|
const offsetN = params.length + 1;
|
|
81
|
-
const page = await
|
|
82
|
+
const page = await executeRawQueryRead<MatchedRow>(
|
|
82
83
|
db,
|
|
83
84
|
`SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
|
|
84
85
|
ORDER BY ${quoteIdent("id")} ASC
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { DbRunner } from "../db/connection";
|
|
9
9
|
import { resolveTableName } from "../db/entity-table-meta";
|
|
10
|
-
import {
|
|
10
|
+
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
11
11
|
import type { Registry, TenantId } from "../engine/types";
|
|
12
12
|
import {
|
|
13
13
|
buildSearchDocument,
|
|
@@ -112,7 +112,7 @@ export async function reindexEntity(
|
|
|
112
112
|
// across both types needs a text cast that breaks integer ordering.
|
|
113
113
|
// This is a one-time backfill over existing rows, not a live hot path;
|
|
114
114
|
// switch to keyset if it ever needs to run against a churning table.
|
|
115
|
-
const rows = await
|
|
115
|
+
const rows = await executeRawQueryRead<Record<string, unknown>>(
|
|
116
116
|
db,
|
|
117
117
|
`SELECT * FROM ${quoteIdent(tableName)}
|
|
118
118
|
WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter}
|
|
@@ -10,7 +10,7 @@ import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
|
|
|
10
10
|
import { setupTestStack, type TestStack } from "../test-stack";
|
|
11
11
|
import { TestUsers } from "../test-users";
|
|
12
12
|
|
|
13
|
-
let stack: TestStack;
|
|
13
|
+
let stack: TestStack | undefined;
|
|
14
14
|
|
|
15
15
|
const pingFeature = defineFeature("reqhelp", (r) => {
|
|
16
16
|
r.writeHandler(
|
|
@@ -49,12 +49,12 @@ beforeAll(async () => {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
afterAll(async () => {
|
|
52
|
-
await stack
|
|
52
|
+
await stack?.cleanup();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
describe("createRequestHelper via setupTestStack.http", () => {
|
|
56
56
|
test("writeOk posts to /api/write and returns handler data", async () => {
|
|
57
|
-
const data = await stack
|
|
57
|
+
const data = await stack!.http.writeOk<{ note: string; userId: string }>(
|
|
58
58
|
"reqhelp:write:echo",
|
|
59
59
|
{ note: "hello" },
|
|
60
60
|
TestUsers.admin,
|
|
@@ -63,7 +63,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("queryOk posts to /api/query and returns handler data", async () => {
|
|
66
|
-
const data = await stack
|
|
66
|
+
const data = await stack!.http.queryOk<{ id: string; ok: boolean }>(
|
|
67
67
|
"reqhelp:query:lookup",
|
|
68
68
|
{ id: "abc" },
|
|
69
69
|
TestUsers.admin,
|
|
@@ -72,14 +72,14 @@ describe("createRequestHelper via setupTestStack.http", () => {
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("writeErr returns structured WriteErrorInfo with httpStatus", async () => {
|
|
75
|
-
const err = await stack
|
|
75
|
+
const err = await stack!.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
|
|
76
76
|
expect(err.code).toBe("unprocessable");
|
|
77
|
-
expect(err.httpStatus).
|
|
77
|
+
expect(err.httpStatus).toBe(422);
|
|
78
78
|
expect(err.i18nKey).toBe("errors.unprocessable");
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
test("queryErr returns structured WriteErrorInfo for not-found", async () => {
|
|
82
|
-
const err = await stack
|
|
82
|
+
const err = await stack!.http.queryErr(
|
|
83
83
|
"reqhelp:query:lookup",
|
|
84
84
|
{ id: "missing" },
|
|
85
85
|
TestUsers.admin,
|
|
@@ -89,13 +89,13 @@ describe("createRequestHelper via setupTestStack.http", () => {
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("writeOk throws when the write fails (so suites cannot ignore failures)", async () => {
|
|
92
|
-
await expect(stack
|
|
92
|
+
await expect(stack!.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
|
|
93
93
|
/reqhelp:write:boom/,
|
|
94
94
|
);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
test("batch posts commands and returns per-command results", async () => {
|
|
98
|
-
const res = await stack
|
|
98
|
+
const res = await stack!.http.batch(
|
|
99
99
|
[
|
|
100
100
|
{ type: "reqhelp:write:echo", payload: { note: "a" } },
|
|
101
101
|
{ type: "reqhelp:write:echo", payload: { note: "b" } },
|
|
@@ -112,7 +112,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
test("writeWithHeaders forwards extra headers alongside auth", async () => {
|
|
115
|
-
const res = await stack
|
|
115
|
+
const res = await stack!.http.writeWithHeaders(
|
|
116
116
|
"reqhelp:write:echo",
|
|
117
117
|
{ note: "hdr" },
|
|
118
118
|
TestUsers.admin,
|
|
@@ -122,5 +122,19 @@ describe("createRequestHelper via setupTestStack.http", () => {
|
|
|
122
122
|
const body = (await res.json()) as { isSuccess?: boolean; data?: { note?: string } };
|
|
123
123
|
expect(body.isSuccess).toBe(true);
|
|
124
124
|
expect(body.data?.note).toBe("hdr");
|
|
125
|
+
expect(res.headers.get("X-Correlation-ID")).toBe("corr-42");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("queryWithHeaders forwards extra headers alongside auth", async () => {
|
|
129
|
+
const res = await stack!.http.queryWithHeaders(
|
|
130
|
+
"reqhelp:query:lookup",
|
|
131
|
+
{ id: "abc" },
|
|
132
|
+
TestUsers.admin,
|
|
133
|
+
{ "X-Correlation-ID": "corr-query" },
|
|
134
|
+
);
|
|
135
|
+
expect(res.ok).toBe(true);
|
|
136
|
+
expect(res.headers.get("X-Correlation-ID")).toBe("corr-query");
|
|
137
|
+
const body = (await res.json()) as { data?: { id?: string; ok?: boolean } };
|
|
138
|
+
expect(body.data).toEqual({ id: "abc", ok: true });
|
|
125
139
|
});
|
|
126
140
|
});
|
package/src/ui-types/index.ts
CHANGED
package/src/upgrade-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readdirSync,
|
|
5
5
|
readFileSync,
|
|
6
6
|
realpathSync,
|
|
7
|
+
statSync,
|
|
7
8
|
writeFileSync,
|
|
8
9
|
} from "node:fs";
|
|
9
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -170,7 +171,23 @@ export function findFeaturesDirs(cwd: string): string[] {
|
|
|
170
171
|
// node_modules after a plain npm/bun install (fw#2301).
|
|
171
172
|
export function findCodemodScriptsRoot(repoRoot: string): string | null {
|
|
172
173
|
const local = join(repoRoot, "packages/framework/src");
|
|
173
|
-
if (existsSync(join(local, CODEMOD_SUBDIR)))
|
|
174
|
+
if (existsSync(join(local, CODEMOD_SUBDIR))) {
|
|
175
|
+
// If package.json exists, require it to be kumiko-framework so a generic
|
|
176
|
+
// monorepo `packages/framework` cannot shadow the installed package.
|
|
177
|
+
// Missing package.json (test fixtures / incomplete trees) keeps prior behavior.
|
|
178
|
+
const pkgPath = join(repoRoot, "packages/framework/package.json");
|
|
179
|
+
if (existsSync(pkgPath)) {
|
|
180
|
+
try {
|
|
181
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string };
|
|
182
|
+
if (pkg.name === "@cosmicdrift/kumiko-framework") return local;
|
|
183
|
+
// wrong name → fall through
|
|
184
|
+
} catch {
|
|
185
|
+
// unreadable → fall through
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
return local;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
174
191
|
|
|
175
192
|
let dir = repoRoot;
|
|
176
193
|
for (let i = 0; i < 10; i++) {
|
|
@@ -187,7 +204,10 @@ export function findCodemodScriptsRoot(repoRoot: string): string | null {
|
|
|
187
204
|
// Resolves a changes.json `codemod` field to an absolute script path,
|
|
188
205
|
// refusing anything that would escape scripts/codemod/ (path traversal,
|
|
189
206
|
// absolute paths, symlinks pointing outward) or that isn't a real .ts file.
|
|
190
|
-
|
|
207
|
+
// First arg is the consumer repo root (same as findCodemodScriptsRoot) —
|
|
208
|
+
// not the scripts root — so out-of-tree callers keep compiling against the
|
|
209
|
+
// public upgrade-cli subpath without silently resolving to null (fw#2341).
|
|
210
|
+
function resolveCodemodScriptAt(
|
|
191
211
|
codemodScriptsRoot: string,
|
|
192
212
|
codemodField: string | undefined,
|
|
193
213
|
): string | null {
|
|
@@ -213,6 +233,15 @@ export function resolveCodemodScript(
|
|
|
213
233
|
return resolved;
|
|
214
234
|
}
|
|
215
235
|
|
|
236
|
+
export function resolveCodemodScript(
|
|
237
|
+
repoRoot: string,
|
|
238
|
+
codemodField: string | undefined,
|
|
239
|
+
): string | null {
|
|
240
|
+
const scriptsRoot = findCodemodScriptsRoot(repoRoot);
|
|
241
|
+
if (!scriptsRoot) return null;
|
|
242
|
+
return resolveCodemodScriptAt(scriptsRoot, codemodField);
|
|
243
|
+
}
|
|
244
|
+
|
|
216
245
|
type CodemodRunResult = { readonly ok: boolean; readonly output: string };
|
|
217
246
|
|
|
218
247
|
// Array-form argv only — never a shell string. The script itself decides
|
|
@@ -254,6 +283,42 @@ function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
|
|
|
254
283
|
writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
|
|
255
284
|
}
|
|
256
285
|
|
|
286
|
+
/** Highest pending version strictly below the earliest open manual breaking
|
|
287
|
+
* entry (no codemod). Falls back to highest non-breaking pending when every
|
|
288
|
+
* pending version is at/after that manual — never advances onto the manual. */
|
|
289
|
+
/** Highest pending version strictly below the earliest open manual breaking
|
|
290
|
+
* entry (no codemod). Falls back to highest non-breaking pending when every
|
|
291
|
+
* pending version is at/after that manual. If only manuals remain, returns
|
|
292
|
+
* `fallbackInstalled` so the marker stamp moves without claiming manuals done. */
|
|
293
|
+
function markerVersionForPending(
|
|
294
|
+
pending: readonly ChangelogEntry[],
|
|
295
|
+
manualEntries: readonly ChangelogEntry[],
|
|
296
|
+
fallbackInstalled: string,
|
|
297
|
+
): string {
|
|
298
|
+
const earliestManual = [...manualEntries].sort((a, b) =>
|
|
299
|
+
compareVersions(a.version, b.version),
|
|
300
|
+
)[0];
|
|
301
|
+
const eligible = pending.filter(
|
|
302
|
+
(e) => earliestManual === undefined || compareVersions(e.version, earliestManual.version) < 0,
|
|
303
|
+
);
|
|
304
|
+
const headEligible = eligible[0];
|
|
305
|
+
if (headEligible !== undefined) {
|
|
306
|
+
return eligible.reduce(
|
|
307
|
+
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
308
|
+
headEligible.version,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const nonBreaking = pending.filter((e) => e.type !== "breaking");
|
|
312
|
+
const headNonBreaking = nonBreaking[0];
|
|
313
|
+
if (headNonBreaking !== undefined) {
|
|
314
|
+
return nonBreaking.reduce(
|
|
315
|
+
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
316
|
+
headNonBreaking.version,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return fallbackInstalled;
|
|
320
|
+
}
|
|
321
|
+
|
|
257
322
|
// Runs every pending breaking entry's codemod, oldest version first (so a
|
|
258
323
|
// later codemod can assume an earlier one already ran). Stops on the first
|
|
259
324
|
// failure — no partial marker. Writes the marker whenever dryRun is false —
|
|
@@ -265,13 +330,16 @@ async function applyCodemods(
|
|
|
265
330
|
repoRoot: string,
|
|
266
331
|
targetDir: string,
|
|
267
332
|
dryRun: boolean,
|
|
268
|
-
|
|
333
|
+
// Installed version for the zero-pending bootstrap marker — never a
|
|
334
|
+
// `--from` filter override (that would permanently skip real pending
|
|
335
|
+
// codemods once the CI guard compares against the fake marker).
|
|
336
|
+
markerVersion: string,
|
|
269
337
|
): Promise<number> {
|
|
270
338
|
if (pending.length === 0) {
|
|
271
339
|
out.log(" ✓ Nothing new since your version.");
|
|
272
340
|
if (!dryRun) {
|
|
273
341
|
writeUpgradeMarker(targetDir, {
|
|
274
|
-
version:
|
|
342
|
+
version: markerVersion,
|
|
275
343
|
appliedAt: Temporal.Now.instant().toString(),
|
|
276
344
|
codemods: [],
|
|
277
345
|
});
|
|
@@ -296,15 +364,28 @@ async function applyCodemods(
|
|
|
296
364
|
? " No automatable codemods among the pending breaking changes."
|
|
297
365
|
: " ✓ No breaking changes pending.",
|
|
298
366
|
);
|
|
367
|
+
if (!dryRun) {
|
|
368
|
+
const markerVer = markerVersionForPending(pending, manualEntries, markerVersion);
|
|
369
|
+
writeUpgradeMarker(targetDir, {
|
|
370
|
+
version: markerVer,
|
|
371
|
+
appliedAt: Temporal.Now.instant().toString(),
|
|
372
|
+
codemods: [],
|
|
373
|
+
});
|
|
374
|
+
out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
|
|
375
|
+
}
|
|
299
376
|
return 0;
|
|
300
377
|
}
|
|
301
378
|
|
|
302
379
|
const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
|
|
380
|
+
if (codemodScriptsRoot === null) {
|
|
381
|
+
out.err(
|
|
382
|
+
` ✗ could not locate @cosmicdrift/kumiko-framework/src/scripts/codemod — is the framework installed? searched from ${repoRoot} upward`,
|
|
383
|
+
);
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
303
386
|
const ran: UpgradeMarkerCodemod[] = [];
|
|
304
387
|
for (const e of codemodEntries) {
|
|
305
|
-
const scriptPath =
|
|
306
|
-
? resolveCodemodScript(codemodScriptsRoot, e.codemod)
|
|
307
|
-
: null;
|
|
388
|
+
const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
|
|
308
389
|
if (!scriptPath) {
|
|
309
390
|
out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
|
|
310
391
|
return 1;
|
|
@@ -325,12 +406,7 @@ async function applyCodemods(
|
|
|
325
406
|
return 0;
|
|
326
407
|
}
|
|
327
408
|
|
|
328
|
-
const
|
|
329
|
-
if (!firstPending) return 0;
|
|
330
|
-
const latestVersion = pending.reduce(
|
|
331
|
-
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
332
|
-
firstPending.version,
|
|
333
|
-
);
|
|
409
|
+
const latestVersion = markerVersionForPending(pending, manualEntries, markerVersion);
|
|
334
410
|
writeUpgradeMarker(targetDir, {
|
|
335
411
|
version: latestVersion,
|
|
336
412
|
appliedAt: Temporal.Now.instant().toString(),
|
|
@@ -398,9 +474,19 @@ export async function runUpgradeCli(
|
|
|
398
474
|
if (getFlag(args, "apply")) {
|
|
399
475
|
const dirFlag = getStringFlag(args, "dir");
|
|
400
476
|
const targetDir = dirFlag ? resolve(dirFlag) : cwd;
|
|
477
|
+
if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
|
|
478
|
+
out.err("");
|
|
479
|
+
out.err(` --dir path is not a directory: ${targetDir}`);
|
|
480
|
+
out.err("");
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
// Marker must reflect what is actually installed under the target (or
|
|
484
|
+
// cwd), not a `--from` filter override — otherwise CI stays green forever.
|
|
485
|
+
const markerVersion =
|
|
486
|
+
(dirFlag ? readCurrentVersion(targetDir) : null) ?? installedVersion ?? currentVersion;
|
|
401
487
|
const dryRun = getFlag(args, "dry-run");
|
|
402
488
|
out.log("");
|
|
403
|
-
const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun,
|
|
489
|
+
const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion);
|
|
404
490
|
out.log("");
|
|
405
491
|
return code;
|
|
406
492
|
}
|