@cosmicdrift/kumiko-framework 0.220.1 → 0.222.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 +7 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
- package/src/api/api-constants.ts +10 -0
- package/src/api/auth-routes.ts +3 -0
- package/src/api/index.ts +1 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/api/server.ts +17 -1
- package/src/arg-parser.ts +1 -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/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/entity-table-meta.ts +6 -1
- 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/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/derivatives/derivatives-context.ts +4 -0
- package/src/derivatives/index.ts +9 -1
- package/src/derivatives/variant-key.ts +68 -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 +7 -7
- 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/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
- package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
- package/src/engine/feature-ast/extractors/index.ts +2 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -1
- package/src/engine/feature-ast/parse.ts +10 -25
- package/src/engine/feature-ast/patch.ts +23 -16
- package/src/engine/feature-ast/render.ts +3 -3
- package/src/engine/field-helpers.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/mixed-schemas.ts +6 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +21 -2
- package/src/errors/__tests__/write-failures.test.ts +13 -3
- package/src/errors/classes.ts +14 -13
- package/src/errors/kumiko-error.ts +11 -11
- package/src/errors/write-error-info.ts +1 -1
- 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__/local-provider.contract.test.ts +14 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- 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/jobs/job-runner.ts +22 -10
- 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/__tests__/tenant-timezone-cache.test.ts +89 -0
- package/src/pipeline/dispatch-shared.ts +39 -2
- package/src/pipeline/dispatch-write.ts +48 -0
- package/src/pipeline/dispatcher.ts +5 -0
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +39 -48
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- 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/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +112 -15
|
@@ -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
|
|
|
@@ -77,7 +77,7 @@ const OVERRIDES_ARG_INDEX_1 = new Set(["createEmbeddedField", "createEmbeddedLis
|
|
|
77
77
|
|
|
78
78
|
const FIELD_FACTORY_RE = /^create\w*Field$/;
|
|
79
79
|
|
|
80
|
-
type ReportEntry = { readonly file: string; readonly line: number; readonly
|
|
80
|
+
type ReportEntry = { readonly file: string; readonly line: number; readonly note: string };
|
|
81
81
|
type FindBucket = "exact" | "fuzzy" | "none" | "secret" | "ref" | "personal-false" | "no-find";
|
|
82
82
|
|
|
83
83
|
const reports: ReportEntry[] = [];
|
|
@@ -92,11 +92,11 @@ const counts: Record<FindBucket, number> = {
|
|
|
92
92
|
"no-find": 0,
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
function report(node: Node,
|
|
95
|
+
function report(node: Node, note: string): void {
|
|
96
96
|
reports.push({
|
|
97
97
|
file: node.getSourceFile().getFilePath(),
|
|
98
98
|
line: node.getStartLineNumber(),
|
|
99
|
-
|
|
99
|
+
note,
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -374,8 +374,8 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
|
|
|
374
374
|
newLookupableSites.push({
|
|
375
375
|
file: obj.getSourceFile().getFilePath(),
|
|
376
376
|
line: subject.prop.getStartLineNumber(),
|
|
377
|
-
//
|
|
378
|
-
|
|
377
|
+
// Guard keys on property name `reason`; this is console copy, not an error code.
|
|
378
|
+
note: "needs a `_bidx` column migration",
|
|
379
379
|
});
|
|
380
380
|
}
|
|
381
381
|
|
|
@@ -469,11 +469,11 @@ async function main(): Promise<void> {
|
|
|
469
469
|
}
|
|
470
470
|
console.log(`\nNewly gains lookupable (needs a _bidx migration): ${newLookupableSites.length}`);
|
|
471
471
|
for (const r of newLookupableSites) {
|
|
472
|
-
console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.
|
|
472
|
+
console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
|
|
473
473
|
}
|
|
474
474
|
console.log(`\nReported (not transformed): ${reports.length}`);
|
|
475
475
|
for (const r of reports) {
|
|
476
|
-
console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.
|
|
476
|
+
console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
|
|
477
477
|
}
|
|
478
478
|
}
|
|
479
479
|
|
|
@@ -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
|
});
|
|
@@ -103,7 +103,7 @@ describe("createRequestHelper", () => {
|
|
|
103
103
|
),
|
|
104
104
|
);
|
|
105
105
|
const http = createRequestHelper(app, jwtStub().jwt);
|
|
106
|
-
expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
|
|
106
|
+
await expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
|
|
107
107
|
'Expected write "todo.create" to succeed but got error: internal_error (DbError: connection lost)',
|
|
108
108
|
);
|
|
109
109
|
});
|
|
@@ -119,7 +119,7 @@ describe("createRequestHelper", () => {
|
|
|
119
119
|
|
|
120
120
|
const succeeding = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
121
121
|
const http2 = createRequestHelper(succeeding.app, jwtStub().jwt);
|
|
122
|
-
expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
|
|
122
|
+
await expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
|
|
123
123
|
'Expected query "q.type" to fail but it succeeded',
|
|
124
124
|
);
|
|
125
125
|
});
|
|
@@ -90,6 +90,25 @@ export function describeFileProviderContract(
|
|
|
90
90
|
await expect(it.next()).rejects.toThrow();
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test("list returns only keys under the given prefix", async () => {
|
|
94
|
+
const group = `contract/list-${crypto.randomUUID()}`;
|
|
95
|
+
const keyA = `${group}/a.txt`;
|
|
96
|
+
const keyB = `${group}/b.txt`;
|
|
97
|
+
const outsideKey = `contract/list-${crypto.randomUUID()}/c.txt`;
|
|
98
|
+
writtenKeys.push(keyA, keyB, outsideKey);
|
|
99
|
+
await provider.write(keyA, bytes("a"));
|
|
100
|
+
await provider.write(keyB, bytes("b"));
|
|
101
|
+
await provider.write(outsideKey, bytes("c"));
|
|
102
|
+
|
|
103
|
+
const listed = await provider.list(`${group}/`);
|
|
104
|
+
expect(new Set(listed)).toEqual(new Set([keyA, keyB]));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("list on a prefix with no matches returns an empty array", async () => {
|
|
108
|
+
const listed = await provider.list(`contract/list-missing-${crypto.randomUUID()}/`);
|
|
109
|
+
expect(listed).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
93
112
|
test("getSignedUrl, when implemented, returns a URL string", async () => {
|
|
94
113
|
// skip: getSignedUrl is optional on the contract — feature-detected
|
|
95
114
|
if (!provider.getSignedUrl) return;
|
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
|
|
@@ -242,10 +271,15 @@ type UpgradeMarkerCodemod = {
|
|
|
242
271
|
readonly codemod: string;
|
|
243
272
|
readonly title: string;
|
|
244
273
|
};
|
|
274
|
+
type UpgradeMarkerManual = {
|
|
275
|
+
readonly version: string;
|
|
276
|
+
readonly title: string;
|
|
277
|
+
};
|
|
245
278
|
type UpgradeMarker = {
|
|
246
279
|
readonly version: string;
|
|
247
280
|
readonly appliedAt: string;
|
|
248
281
|
readonly codemods: readonly UpgradeMarkerCodemod[];
|
|
282
|
+
readonly pendingManual?: readonly UpgradeMarkerManual[];
|
|
249
283
|
};
|
|
250
284
|
|
|
251
285
|
function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
|
|
@@ -254,24 +288,64 @@ function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
|
|
|
254
288
|
writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
|
|
255
289
|
}
|
|
256
290
|
|
|
291
|
+
/** Highest pending version strictly below the earliest open manual breaking
|
|
292
|
+
* entry (no codemod). Falls back to highest non-breaking pending when every
|
|
293
|
+
* pending version is at/after that manual — never advances onto the manual. */
|
|
294
|
+
/** Highest pending version strictly below the earliest open manual breaking
|
|
295
|
+
* entry (no codemod). Falls back to highest non-breaking pending when every
|
|
296
|
+
* pending version is at/after that manual. If only manuals remain, returns
|
|
297
|
+
* `fallbackInstalled` so the marker stamp moves without claiming manuals done. */
|
|
298
|
+
function markerVersionForPending(
|
|
299
|
+
pending: readonly ChangelogEntry[],
|
|
300
|
+
manualEntries: readonly ChangelogEntry[],
|
|
301
|
+
fallbackInstalled: string,
|
|
302
|
+
): string {
|
|
303
|
+
const earliestManual = [...manualEntries].sort((a, b) =>
|
|
304
|
+
compareVersions(a.version, b.version),
|
|
305
|
+
)[0];
|
|
306
|
+
const eligible = pending.filter(
|
|
307
|
+
(e) => earliestManual === undefined || compareVersions(e.version, earliestManual.version) < 0,
|
|
308
|
+
);
|
|
309
|
+
const headEligible = eligible[0];
|
|
310
|
+
if (headEligible !== undefined) {
|
|
311
|
+
return eligible.reduce(
|
|
312
|
+
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
313
|
+
headEligible.version,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const nonBreaking = pending.filter((e) => e.type !== "breaking");
|
|
317
|
+
const headNonBreaking = nonBreaking[0];
|
|
318
|
+
if (headNonBreaking !== undefined) {
|
|
319
|
+
return nonBreaking.reduce(
|
|
320
|
+
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
321
|
+
headNonBreaking.version,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return fallbackInstalled;
|
|
325
|
+
}
|
|
326
|
+
|
|
257
327
|
// Runs every pending breaking entry's codemod, oldest version first (so a
|
|
258
328
|
// later codemod can assume an earlier one already ran). Stops on the first
|
|
259
329
|
// failure — no partial marker. Writes the marker whenever dryRun is false —
|
|
260
330
|
// even with zero pending entries, so an already-current app still gets a
|
|
261
331
|
// bootstrap marker recording its installed version (fw#2299).
|
|
332
|
+
// kumiko-lint-ignore complexity-budget sequential codemod runner with zero-pending bootstrap marker
|
|
262
333
|
async function applyCodemods(
|
|
263
334
|
out: UpgradeCliOut,
|
|
264
335
|
pending: readonly ChangelogEntry[],
|
|
265
336
|
repoRoot: string,
|
|
266
337
|
targetDir: string,
|
|
267
338
|
dryRun: boolean,
|
|
268
|
-
|
|
339
|
+
// Installed version for the zero-pending bootstrap marker — never a
|
|
340
|
+
// `--from` filter override (that would permanently skip real pending
|
|
341
|
+
// codemods once the CI guard compares against the fake marker).
|
|
342
|
+
markerVersion: string,
|
|
269
343
|
): Promise<number> {
|
|
270
344
|
if (pending.length === 0) {
|
|
271
345
|
out.log(" ✓ Nothing new since your version.");
|
|
272
346
|
if (!dryRun) {
|
|
273
347
|
writeUpgradeMarker(targetDir, {
|
|
274
|
-
version:
|
|
348
|
+
version: markerVersion,
|
|
275
349
|
appliedAt: Temporal.Now.instant().toString(),
|
|
276
350
|
codemods: [],
|
|
277
351
|
});
|
|
@@ -284,7 +358,7 @@ async function applyCodemods(
|
|
|
284
358
|
const codemodEntries = breaking
|
|
285
359
|
.filter(hasCodemod)
|
|
286
360
|
.sort((a, b) => compareVersions(a.version, b.version));
|
|
287
|
-
const manualEntries = breaking.filter((e) => !e
|
|
361
|
+
const manualEntries = breaking.filter((e) => !hasCodemod(e));
|
|
288
362
|
|
|
289
363
|
for (const e of manualEntries) {
|
|
290
364
|
out.log(` ⚠ ${e.version} · ${e.title} — no codemod, manual migration required`);
|
|
@@ -296,15 +370,28 @@ async function applyCodemods(
|
|
|
296
370
|
? " No automatable codemods among the pending breaking changes."
|
|
297
371
|
: " ✓ No breaking changes pending.",
|
|
298
372
|
);
|
|
373
|
+
if (!dryRun) {
|
|
374
|
+
const markerVer = markerVersionForPending(pending, manualEntries, markerVersion);
|
|
375
|
+
writeUpgradeMarker(targetDir, {
|
|
376
|
+
version: markerVer,
|
|
377
|
+
appliedAt: Temporal.Now.instant().toString(),
|
|
378
|
+
codemods: [],
|
|
379
|
+
});
|
|
380
|
+
out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
|
|
381
|
+
}
|
|
299
382
|
return 0;
|
|
300
383
|
}
|
|
301
384
|
|
|
302
385
|
const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
|
|
386
|
+
if (codemodScriptsRoot === null) {
|
|
387
|
+
out.err(
|
|
388
|
+
` ✗ could not locate @cosmicdrift/kumiko-framework/src/scripts/codemod — is the framework installed? searched from ${repoRoot} upward`,
|
|
389
|
+
);
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
303
392
|
const ran: UpgradeMarkerCodemod[] = [];
|
|
304
393
|
for (const e of codemodEntries) {
|
|
305
|
-
const scriptPath =
|
|
306
|
-
? resolveCodemodScript(codemodScriptsRoot, e.codemod)
|
|
307
|
-
: null;
|
|
394
|
+
const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
|
|
308
395
|
if (!scriptPath) {
|
|
309
396
|
out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
|
|
310
397
|
return 1;
|
|
@@ -325,20 +412,20 @@ async function applyCodemods(
|
|
|
325
412
|
return 0;
|
|
326
413
|
}
|
|
327
414
|
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
const latestVersion = pending.reduce(
|
|
331
|
-
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
332
|
-
firstPending.version,
|
|
333
|
-
);
|
|
415
|
+
const latestVersion = markerVersionForPending(pending, manualEntries, markerVersion);
|
|
416
|
+
const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
|
|
334
417
|
writeUpgradeMarker(targetDir, {
|
|
335
418
|
version: latestVersion,
|
|
336
419
|
appliedAt: Temporal.Now.instant().toString(),
|
|
337
420
|
codemods: ran,
|
|
421
|
+
...(pendingManual.length > 0 && { pendingManual }),
|
|
338
422
|
});
|
|
339
423
|
out.log(
|
|
340
424
|
` ✓ Applied ${ran.length} codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`,
|
|
341
425
|
);
|
|
426
|
+
if (pendingManual.length > 0) {
|
|
427
|
+
out.log(` ⚠ ${pendingManual.length} breaking change(s) still need manual migration.`);
|
|
428
|
+
}
|
|
342
429
|
return 0;
|
|
343
430
|
}
|
|
344
431
|
|
|
@@ -398,9 +485,19 @@ export async function runUpgradeCli(
|
|
|
398
485
|
if (getFlag(args, "apply")) {
|
|
399
486
|
const dirFlag = getStringFlag(args, "dir");
|
|
400
487
|
const targetDir = dirFlag ? resolve(dirFlag) : cwd;
|
|
488
|
+
if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
|
|
489
|
+
out.err("");
|
|
490
|
+
out.err(` --dir path is not a directory: ${targetDir}`);
|
|
491
|
+
out.err("");
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
// Marker must reflect what is actually installed under the target (or
|
|
495
|
+
// cwd), not a `--from` filter override — otherwise CI stays green forever.
|
|
496
|
+
const markerVersion =
|
|
497
|
+
(dirFlag ? readCurrentVersion(targetDir) : null) ?? installedVersion ?? currentVersion;
|
|
401
498
|
const dryRun = getFlag(args, "dry-run");
|
|
402
499
|
out.log("");
|
|
403
|
-
const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun,
|
|
500
|
+
const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion);
|
|
404
501
|
out.log("");
|
|
405
502
|
return code;
|
|
406
503
|
}
|