@cosmicdrift/kumiko-framework 0.269.0 → 0.269.2
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__/fixtures/migrate-db-raw/global-fetch.expected.ts +9 -0
- package/src/__tests__/fixtures/migrate-db-raw/global-fetch.input.ts +10 -0
- package/src/__tests__/fixtures/migrate-db-raw/global-select.expected.ts +6 -0
- package/src/__tests__/fixtures/migrate-db-raw/global-select.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-db-raw/import-pruning.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-db-raw/import-pruning.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-db-raw/manual-only.expected.ts +26 -0
- package/src/__tests__/fixtures/migrate-db-raw/manual-only.input.ts +26 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-const-shorthand.expected.ts +10 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-const-shorthand.input.ts +11 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-event.expected.ts +9 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-event.input.ts +15 -0
- package/src/__tests__/migrate-db-raw-codemod.test.ts +132 -0
- package/src/__tests__/upgrade-cli.test.ts +54 -0
- package/src/changes.json +2 -2
- package/src/scripts/codemod/migrate-db-raw.ts +397 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.269.
|
|
3
|
+
"version": "0.269.2",
|
|
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>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.269.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.269.2",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.269.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.269.2",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { userTable } from "./entities/user";
|
|
2
|
+
|
|
3
|
+
export const userDetail = defineQueryHandler({
|
|
4
|
+
name: "user:detail",
|
|
5
|
+
handler: async (query, ctx) => {
|
|
6
|
+
const row = await ctx.db.global(userTable).fetchOne<UserRow>({ id: query.payload.id });
|
|
7
|
+
return row ?? null;
|
|
8
|
+
},
|
|
9
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { userTable } from "./entities/user";
|
|
3
|
+
|
|
4
|
+
export const userDetail = defineQueryHandler({
|
|
5
|
+
name: "user:detail",
|
|
6
|
+
handler: async (query, ctx) => {
|
|
7
|
+
const row = await fetchOne<UserRow>(ctx.db.raw, userTable, { id: query.payload.id });
|
|
8
|
+
return row ?? null;
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { globalFeatureStateTable } from "./entities/global-feature-state";
|
|
3
|
+
|
|
4
|
+
export async function loadFlags(ctx: Ctx) {
|
|
5
|
+
const rows = await selectMany<FlagRow>(
|
|
6
|
+
ctx.db.raw,
|
|
7
|
+
globalFeatureStateTable,
|
|
8
|
+
{ key: "beta" },
|
|
9
|
+
{ limit: 10 },
|
|
10
|
+
);
|
|
11
|
+
return rows;
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { userTable } from "./entities/user";
|
|
3
|
+
|
|
4
|
+
export const userDetail = defineQueryHandler({
|
|
5
|
+
name: "user:detail",
|
|
6
|
+
handler: async (query, ctx) => {
|
|
7
|
+
const row = await ctx.db.global(userTable).fetchOne<UserRow>({ id: query.payload.id });
|
|
8
|
+
return row ?? null;
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const selectManyHelper = selectMany;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { userTable } from "./entities/user";
|
|
3
|
+
|
|
4
|
+
export const userDetail = defineQueryHandler({
|
|
5
|
+
name: "user:detail",
|
|
6
|
+
handler: async (query, ctx) => {
|
|
7
|
+
const row = await fetchOne<UserRow>(ctx.db.raw, userTable, { id: query.payload.id });
|
|
8
|
+
return row ?? null;
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const selectManyHelper = selectMany;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { countWhere, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { auditTable, invoiceTable } from "./entities";
|
|
4
|
+
|
|
5
|
+
export const auditWrite = defineWriteHandler({
|
|
6
|
+
name: "audit:write",
|
|
7
|
+
handler: async (event, ctx) => {
|
|
8
|
+
const tenantId = event.payload.tenantId;
|
|
9
|
+
await insertOne(ctx.db.raw, auditTable, { tenantId, action: event.payload.action });
|
|
10
|
+
const total = await countWhere(ctx.db.raw, invoiceTable, { status: "open" });
|
|
11
|
+
return { total };
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function bootstrapSystemDb(ctx: Ctx, x: unknown) {
|
|
16
|
+
return createTenantDb(ctx.db.raw, x, "system");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const crossTenantAck = defineWriteHandler({
|
|
20
|
+
name: "cross-tenant:ack",
|
|
21
|
+
handler: async (event, ctx) => {
|
|
22
|
+
const r = { id: event.payload.id };
|
|
23
|
+
const acked = ctx.systemDb.acknowledgeCrossTenant(r).raw;
|
|
24
|
+
return { acked };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { countWhere, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { auditTable, invoiceTable } from "./entities";
|
|
4
|
+
|
|
5
|
+
export const auditWrite = defineWriteHandler({
|
|
6
|
+
name: "audit:write",
|
|
7
|
+
handler: async (event, ctx) => {
|
|
8
|
+
const tenantId = event.payload.tenantId;
|
|
9
|
+
await insertOne(ctx.db.raw, auditTable, { tenantId, action: event.payload.action });
|
|
10
|
+
const total = await countWhere(ctx.db.raw, invoiceTable, { status: "open" });
|
|
11
|
+
return { total };
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function bootstrapSystemDb(ctx: Ctx, x: unknown) {
|
|
16
|
+
return createTenantDb(ctx.db.raw, x, "system");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const crossTenantAck = defineWriteHandler({
|
|
20
|
+
name: "cross-tenant:ack",
|
|
21
|
+
handler: async (event, ctx) => {
|
|
22
|
+
const r = { id: event.payload.id };
|
|
23
|
+
const acked = ctx.systemDb.acknowledgeCrossTenant(r).raw;
|
|
24
|
+
return { acked };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { invoiceTable } from "./entities/invoice";
|
|
2
|
+
|
|
3
|
+
export const invoiceDetailQuery = defineQueryHandler({
|
|
4
|
+
name: "invoice:detail",
|
|
5
|
+
handler: async (query, ctx) => {
|
|
6
|
+
const tenantId = query.user.tenantId;
|
|
7
|
+
const row = await ctx.db.fetchOne<InvoiceRow>(invoiceTable, { id: query.payload.id, tenantId });
|
|
8
|
+
return row ?? null;
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { invoiceTable } from "./entities/invoice";
|
|
3
|
+
|
|
4
|
+
export const invoiceDetailQuery = defineQueryHandler({
|
|
5
|
+
name: "invoice:detail",
|
|
6
|
+
handler: async (query, ctx) => {
|
|
7
|
+
const tenantId = query.user.tenantId;
|
|
8
|
+
const row = await fetchOne<InvoiceRow>(ctx.db.raw, invoiceTable, { id: query.payload.id, tenantId });
|
|
9
|
+
return row ?? null;
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { invoiceTable } from "./entities/invoice";
|
|
2
|
+
|
|
3
|
+
export const invoiceListWrite = defineWriteHandler({
|
|
4
|
+
name: "invoice:list",
|
|
5
|
+
handler: async (event, ctx) => {
|
|
6
|
+
const rows = await ctx.db.selectMany(invoiceTable, { tenantId: event.user.tenantId, status: "open" }, { limit: 20 });
|
|
7
|
+
return rows;
|
|
8
|
+
},
|
|
9
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { invoiceTable } from "./entities/invoice";
|
|
3
|
+
|
|
4
|
+
export const invoiceListWrite = defineWriteHandler({
|
|
5
|
+
name: "invoice:list",
|
|
6
|
+
handler: async (event, ctx) => {
|
|
7
|
+
const rows = await selectMany(
|
|
8
|
+
ctx.db.raw,
|
|
9
|
+
invoiceTable,
|
|
10
|
+
{ tenantId: event.user.tenantId, status: "open" },
|
|
11
|
+
{ limit: 20 },
|
|
12
|
+
);
|
|
13
|
+
return rows;
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { migrateDbRawSource } from "../scripts/codemod/migrate-db-raw";
|
|
5
|
+
|
|
6
|
+
const FIXTURES_DIR = join(import.meta.dir, "fixtures", "migrate-db-raw");
|
|
7
|
+
const DEFAULT_GLOBAL_TABLES = new Set(["userTable", "globalFeatureStateTable"]);
|
|
8
|
+
|
|
9
|
+
function readFixture(name: string): { input: string; expected: string } {
|
|
10
|
+
return {
|
|
11
|
+
input: readFileSync(join(FIXTURES_DIR, `${name}.input.ts`), "utf8"),
|
|
12
|
+
expected: readFileSync(join(FIXTURES_DIR, `${name}.expected.ts`), "utf8"),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("migrateDbRawSource", () => {
|
|
17
|
+
it("rewrites a global fetchOne with type args to db.global().fetchOne()", () => {
|
|
18
|
+
const { input, expected } = readFixture("global-fetch");
|
|
19
|
+
const result = migrateDbRawSource(input, "global-fetch.ts", {
|
|
20
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.output).toBe(expected);
|
|
24
|
+
expect(result.rewrites).toHaveLength(1);
|
|
25
|
+
expect(result.rewrites[0]?.rule).toBe("global-table");
|
|
26
|
+
expect(result.rewrites[0]?.line).toBe(7);
|
|
27
|
+
expect(result.manual).toHaveLength(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rewrites a global selectMany with options to db.global().selectMany()", () => {
|
|
31
|
+
const { input, expected } = readFixture("global-select");
|
|
32
|
+
const result = migrateDbRawSource(input, "global-select.ts", {
|
|
33
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result.output).toBe(expected);
|
|
37
|
+
expect(result.rewrites).toHaveLength(1);
|
|
38
|
+
expect(result.rewrites[0]?.rule).toBe("global-table");
|
|
39
|
+
expect(result.manual).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rewrites own-tenant filtering via event.user.tenantId", () => {
|
|
43
|
+
const { input, expected } = readFixture("own-tenant-event");
|
|
44
|
+
const result = migrateDbRawSource(input, "own-tenant-event.ts", {
|
|
45
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(result.output).toBe(expected);
|
|
49
|
+
expect(result.rewrites).toHaveLength(1);
|
|
50
|
+
expect(result.rewrites[0]?.rule).toBe("own-tenant");
|
|
51
|
+
expect(result.manual).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rewrites own-tenant filtering via a const tenantId shorthand binding", () => {
|
|
55
|
+
const { input, expected } = readFixture("own-tenant-const-shorthand");
|
|
56
|
+
const result = migrateDbRawSource(input, "own-tenant-const-shorthand.ts", {
|
|
57
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result.output).toBe(expected);
|
|
61
|
+
expect(result.rewrites).toHaveLength(1);
|
|
62
|
+
expect(result.rewrites[0]?.rule).toBe("own-tenant");
|
|
63
|
+
expect(result.manual).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("leaves unsafe call sites untouched and reports them as manual", () => {
|
|
67
|
+
const { input, expected } = readFixture("manual-only");
|
|
68
|
+
const result = migrateDbRawSource(input, "manual-only.ts", {
|
|
69
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.output).toBe(expected);
|
|
73
|
+
expect(result.rewrites).toHaveLength(0);
|
|
74
|
+
expect(result.manual).toHaveLength(4);
|
|
75
|
+
|
|
76
|
+
const byText = new Map(result.manual.map((m) => [m.text, m]));
|
|
77
|
+
|
|
78
|
+
const insertOneSite = byText.get(
|
|
79
|
+
"insertOne(ctx.db.raw, auditTable, { tenantId, action: event.payload.action })",
|
|
80
|
+
);
|
|
81
|
+
expect(insertOneSite).toBeDefined();
|
|
82
|
+
expect(insertOneSite?.line).toBe(9);
|
|
83
|
+
expect(insertOneSite?.enclosing).toBe("handler");
|
|
84
|
+
|
|
85
|
+
const countWhereSite = byText.get('countWhere(ctx.db.raw, invoiceTable, { status: "open" })');
|
|
86
|
+
expect(countWhereSite).toBeDefined();
|
|
87
|
+
expect(countWhereSite?.line).toBe(10);
|
|
88
|
+
|
|
89
|
+
const createTenantDbSite = byText.get('createTenantDb(ctx.db.raw, x, "system")');
|
|
90
|
+
expect(createTenantDbSite).toBeDefined();
|
|
91
|
+
expect(createTenantDbSite?.line).toBe(16);
|
|
92
|
+
expect(createTenantDbSite?.enclosing).toBe("bootstrapSystemDb");
|
|
93
|
+
|
|
94
|
+
const systemDbSite = byText.get("ctx.systemDb.acknowledgeCrossTenant(r).raw");
|
|
95
|
+
expect(systemDbSite).toBeDefined();
|
|
96
|
+
expect(systemDbSite?.line).toBe(23);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not rewrite when tenantId comes from a non-user-scope source (payload/token)", () => {
|
|
100
|
+
const { input } = readFixture("manual-only");
|
|
101
|
+
const result = migrateDbRawSource(input, "manual-only.ts", {
|
|
102
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.output).toContain("await insertOne(ctx.db.raw, auditTable");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("prunes an unused free-function import after rewriting its only call site", () => {
|
|
109
|
+
const { input, expected } = readFixture("global-fetch");
|
|
110
|
+
const result = migrateDbRawSource(input, "global-fetch.ts", {
|
|
111
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.output).not.toContain(
|
|
115
|
+
'import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";',
|
|
116
|
+
);
|
|
117
|
+
expect(result.output).toBe(expected);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps a still-referenced sibling import while pruning the rewritten one", () => {
|
|
121
|
+
const { input, expected } = readFixture("import-pruning");
|
|
122
|
+
const result = migrateDbRawSource(input, "import-pruning.ts", {
|
|
123
|
+
globalTables: DEFAULT_GLOBAL_TABLES,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.output).toBe(expected);
|
|
127
|
+
expect(result.output).toContain(
|
|
128
|
+
'import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";',
|
|
129
|
+
);
|
|
130
|
+
expect(result.output.split("\n")[0]).not.toContain("fetchOne");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
5
5
|
mkdtempSync,
|
|
6
|
+
readdirSync,
|
|
6
7
|
readFileSync,
|
|
7
8
|
rmSync,
|
|
9
|
+
statSync,
|
|
8
10
|
symlinkSync,
|
|
9
11
|
writeFileSync,
|
|
10
12
|
} from "node:fs";
|
|
@@ -12,6 +14,8 @@ import { tmpdir } from "node:os";
|
|
|
12
14
|
import { join } from "node:path";
|
|
13
15
|
import {
|
|
14
16
|
findCodemodScriptsRoot,
|
|
17
|
+
findCoreChangelogFile,
|
|
18
|
+
findFeaturesDirs,
|
|
15
19
|
resolveCodemodScript,
|
|
16
20
|
runUpgradeCli,
|
|
17
21
|
type UpgradeCliOut,
|
|
@@ -592,3 +596,53 @@ describe("upgrade command — filter baseline is the marker, not the installed v
|
|
|
592
596
|
expect(result.pending.map((e) => e.title)).toEqual(["explicit-from-test-entry"]);
|
|
593
597
|
});
|
|
594
598
|
});
|
|
599
|
+
|
|
600
|
+
describe("changes.json codemod fields resolve to real published scripts", () => {
|
|
601
|
+
function findChangesJsonFiles(dir: string): string[] {
|
|
602
|
+
const found: string[] = [];
|
|
603
|
+
for (const name of readdirSync(dir)) {
|
|
604
|
+
if (name === "node_modules") continue;
|
|
605
|
+
const full = join(dir, name);
|
|
606
|
+
if (statSync(full).isDirectory()) {
|
|
607
|
+
found.push(...findChangesJsonFiles(full));
|
|
608
|
+
} else if (name === "changes.json") {
|
|
609
|
+
found.push(full);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return found;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
test("every codemod field is a scripts/codemod/*.ts path that resolves to an existing published script", () => {
|
|
616
|
+
const coreChangelog = findCoreChangelogFile(REAL_REPO_ROOT);
|
|
617
|
+
const changesJsonFiles = [
|
|
618
|
+
...(coreChangelog ? [coreChangelog] : []),
|
|
619
|
+
...findFeaturesDirs(REAL_REPO_ROOT).flatMap((dir) => findChangesJsonFiles(dir)),
|
|
620
|
+
];
|
|
621
|
+
expect(changesJsonFiles.length).toBeGreaterThan(0);
|
|
622
|
+
|
|
623
|
+
const offenders: string[] = [];
|
|
624
|
+
for (const file of changesJsonFiles) {
|
|
625
|
+
const entries = JSON.parse(readFileSync(file, "utf-8")) as ReadonlyArray<{
|
|
626
|
+
readonly version: string;
|
|
627
|
+
readonly title: string;
|
|
628
|
+
readonly codemod?: string;
|
|
629
|
+
}>;
|
|
630
|
+
for (const entry of entries) {
|
|
631
|
+
if (typeof entry.codemod !== "string") continue;
|
|
632
|
+
if (/\s/.test(entry.codemod)) {
|
|
633
|
+
offenders.push(
|
|
634
|
+
`${file} · ${entry.version} "${entry.title}": codemod is a shell command, not a path: "${entry.codemod}"`,
|
|
635
|
+
);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (resolveCodemodScript(REAL_REPO_ROOT, entry.codemod) === null) {
|
|
639
|
+
offenders.push(
|
|
640
|
+
`${file} · ${entry.version} "${entry.title}": codemod path does not resolve: "${entry.codemod}"`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
expect(offenders).toEqual([]);
|
|
647
|
+
});
|
|
648
|
+
});
|
package/src/changes.json
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"type": "breaking",
|
|
18
18
|
"title": "ctx.db.unsafeRaw(reason) gated by escapeHatch, tenancy: \"global\" invariants, bundled features migrated off ctx.db.raw (fw#2858).",
|
|
19
19
|
"detail": "`TenantDb.unsafeRaw(reason)` returns the unfiltered `DbRunner` only when the calling write or query handler declares `escapeHatch: { reason }` (lifecycle hooks: `r.hook(..., { escapeHatch })`; a hook no longer inherits the handler's grant, same as the #2859 SYSTEM identity-switch gate). Without a declaration it throws `AccessDeniedError`; an empty reason throws `Error`. `r.systemScope()` features keep using `ctx.systemDb.unsafeRaw(reason)`. `TenantDb.raw` stays deprecated until fw#2860. `createTenantDb`'s 7th parameter changed from an `EscapeHatchDeclaration` to `TenantDbGrants` `{ globalWrites?, unsafeRaw? }`; `withUnsafeRawGrant(tenantDb, grant)` rebinds the unsafeRaw grant. `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant` are renamed to `bindHookEscapeHatchGrant`/`withHookEscapeHatchGrant`. Global tenancy: a `tenancy: \"global\"` entity must declare `systemStream: true` (boot validator + executor) and its create/update payloads may not carry a non-SYSTEM `tenantId`; tenant-mode `insertOne` into a global table with a `tenant_id` column and `db.global(table)` writes with a non-SYSTEM `tenantId` are rejected; `defineUnmanagedTable({ tenancy: \"global\" })` rejects a `tenant_id` column; new `declareGlobalTenancy(table)` declares a plain `table()` store without `tenant_id` as global. Bundled features: `userEntity` is `tenancy: \"global\"`, `store_global_feature_state` is declared global, and every bundled `ctx.db.raw` call site now uses `ctx.db.global(table)`, a filtered `ctx.db` method, or `ctx.db.unsafeRaw(reason)` with an `escapeHatch` on the handler. Handlers that call the auth-mfa `mfaStatusChecker`/MFA code verifier callbacks, handlers wrapped by cap-counter `withStockCap` (declares it automatically) and callers of the custom-fields quota/field-access query helpers now need that declaration.",
|
|
20
|
-
"migration": "Replace each `ctx.db.raw` use: reads of a `tenancy: \"global\"` table → `ctx.db.global(table).selectMany/fetchOne(...)`; reads/inserts whose where/values only use the caller's own tenantId → `ctx.db.selectMany/fetchOne/insertOne(table, ...)` (method form); everything else → `ctx.db.unsafeRaw(\"<what is read/written and why the tenant filter cannot apply>\")` plus `escapeHatch: { reason }` on the write/query handler definition or in the `r.hook` options. Generic reasons (todo, legacy, migration) fail infra#788. `
|
|
21
|
-
"codemod": "
|
|
20
|
+
"migration": "Replace each `ctx.db.raw` use: reads of a `tenancy: \"global\"` table → `ctx.db.global(table).selectMany/fetchOne(...)`; reads/inserts whose where/values only use the caller's own tenantId → `ctx.db.selectMany/fetchOne/insertOne(table, ...)` (method form); everything else → `ctx.db.unsafeRaw(\"<what is read/written and why the tenant filter cannot apply>\")` plus `escapeHatch: { reason }` on the write/query handler definition or in the `r.hook` options. Generic reasons (todo, legacy, migration) fail infra#788. `kumiko-upgrade --apply` runs the migrate-db-raw codemod automatically, rewriting the global-table and own-tenant cases and listing the remaining sites for manual review. Custom handlers calling `mfaStatusChecker`/the MFA code verifier or the custom-fields query helpers must declare `escapeHatch`. Direct `createTenantDb(..., escapeHatch)` calls pass `{ globalWrites: escapeHatch }` (and `unsafeRaw` if needed). Rename imports of `bindHookIdentitySwitchGrant`/`withHookIdentitySwitchGrant`. `defineUnmanagedTable({ tenancy: \"global\" })` definitions must drop their `tenant_id` column; `tenancy: \"global\"` entities must add `systemStream: true`.",
|
|
21
|
+
"codemod": "scripts/codemod/migrate-db-raw.ts"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"version": "0.267.0",
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// migrate-db-raw — rewrites standalone bun-db helper calls that manually pass
|
|
3
|
+
// ctx.db.raw to their TenantDb method-form equivalent (db.global(table)... /
|
|
4
|
+
// db.selectMany|fetchOne|insertOne(...)) wherever the rewrite is provably
|
|
5
|
+
// safe, and reports every remaining ctx.db.raw-ish access for manual review.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// bun scripts/codemod/migrate-db-raw.ts [--dry-run] [--global-tables a,b] <path...>
|
|
9
|
+
|
|
10
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { Node, Project, type SourceFile, SyntaxKind, VariableDeclarationKind } from "ts-morph";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_GLOBAL_TABLES: ReadonlySet<string> = new Set([
|
|
15
|
+
"userTable",
|
|
16
|
+
"globalFeatureStateTable",
|
|
17
|
+
]);
|
|
18
|
+
const PRUNABLE_FN_NAMES: ReadonlySet<string> = new Set(["selectMany", "fetchOne", "insertOne"]);
|
|
19
|
+
const OWN_TENANT_FN_NAMES: ReadonlySet<string> = new Set(["selectMany", "fetchOne", "insertOne"]);
|
|
20
|
+
const GLOBAL_TABLE_FN_NAMES: ReadonlySet<string> = new Set(["selectMany", "fetchOne"]);
|
|
21
|
+
const TENANT_ID_SOURCES: ReadonlySet<string> = new Set([
|
|
22
|
+
"event.user.tenantId",
|
|
23
|
+
"query.user.tenantId",
|
|
24
|
+
"ctx.user.tenantId",
|
|
25
|
+
]);
|
|
26
|
+
const RAW_RECEIVER_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
27
|
+
"db",
|
|
28
|
+
"tdb",
|
|
29
|
+
"scopedDb",
|
|
30
|
+
"outsideTx",
|
|
31
|
+
]);
|
|
32
|
+
const MANUAL_TEXT_LIMIT = 100;
|
|
33
|
+
|
|
34
|
+
export type RewriteRule = "global-table" | "own-tenant";
|
|
35
|
+
|
|
36
|
+
export interface Rewrite {
|
|
37
|
+
readonly file: string;
|
|
38
|
+
readonly line: number;
|
|
39
|
+
readonly rule: RewriteRule;
|
|
40
|
+
readonly before: string;
|
|
41
|
+
readonly after: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ManualSite {
|
|
45
|
+
readonly file: string;
|
|
46
|
+
readonly line: number;
|
|
47
|
+
readonly enclosing: string;
|
|
48
|
+
readonly text: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MigrateDbRawResult {
|
|
52
|
+
readonly output: string;
|
|
53
|
+
readonly rewrites: Rewrite[];
|
|
54
|
+
readonly manual: ManualSite[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRawOnCtxDb(argExpr: Node): boolean {
|
|
58
|
+
return argExpr.getText() === "ctx.db.raw";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function enclosingFunctionScope(node: Node): Node {
|
|
62
|
+
return (
|
|
63
|
+
node.getFirstAncestor(
|
|
64
|
+
(n) =>
|
|
65
|
+
Node.isFunctionDeclaration(n) ||
|
|
66
|
+
Node.isArrowFunction(n) ||
|
|
67
|
+
Node.isFunctionExpression(n) ||
|
|
68
|
+
Node.isMethodDeclaration(n),
|
|
69
|
+
) ?? node.getSourceFile()
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isConstTenantIdBinding(scope: Node, varName: string): boolean {
|
|
74
|
+
return scope.getDescendantsOfKind(SyntaxKind.VariableDeclaration).some((decl) => {
|
|
75
|
+
if (decl.getName() !== varName) return false;
|
|
76
|
+
const statement = decl.getVariableStatement();
|
|
77
|
+
if (!statement || statement.getDeclarationKind() !== VariableDeclarationKind.Const)
|
|
78
|
+
return false;
|
|
79
|
+
const initializer = decl.getInitializer();
|
|
80
|
+
return initializer !== undefined && TENANT_ID_SOURCES.has(initializer.getText());
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isValidOwnTenantIdValue(valueNode: Node): boolean {
|
|
85
|
+
if (TENANT_ID_SOURCES.has(valueNode.getText())) return true;
|
|
86
|
+
if (!Node.isIdentifier(valueNode)) return false;
|
|
87
|
+
return isConstTenantIdBinding(enclosingFunctionScope(valueNode), valueNode.getText());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function objectHasValidOwnTenantId(obj: Node): boolean {
|
|
91
|
+
if (!Node.isObjectLiteralExpression(obj)) return false;
|
|
92
|
+
for (const prop of obj.getProperties()) {
|
|
93
|
+
if (Node.isPropertyAssignment(prop) && prop.getName() === "tenantId") {
|
|
94
|
+
const init = prop.getInitializer();
|
|
95
|
+
return init !== undefined && isValidOwnTenantIdValue(init);
|
|
96
|
+
}
|
|
97
|
+
if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === "tenantId") {
|
|
98
|
+
return isConstTenantIdBinding(enclosingFunctionScope(prop), "tenantId");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface PendingRewrite {
|
|
105
|
+
readonly callExpr: Node;
|
|
106
|
+
readonly rule: RewriteRule;
|
|
107
|
+
readonly before: string;
|
|
108
|
+
readonly after: string;
|
|
109
|
+
readonly line: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function planRewrite(
|
|
113
|
+
callExpr: Node,
|
|
114
|
+
globalTables: ReadonlySet<string>,
|
|
115
|
+
): PendingRewrite | undefined {
|
|
116
|
+
if (!Node.isCallExpression(callExpr)) return undefined;
|
|
117
|
+
const calleeExpr = callExpr.getExpression();
|
|
118
|
+
if (!Node.isIdentifier(calleeExpr)) return undefined;
|
|
119
|
+
const fnName = calleeExpr.getText();
|
|
120
|
+
const args = callExpr.getArguments();
|
|
121
|
+
if (args.length === 0 || !isRawOnCtxDb(args[0]!)) return undefined;
|
|
122
|
+
|
|
123
|
+
const typeArgs = callExpr.getTypeArguments();
|
|
124
|
+
const typeArgsText =
|
|
125
|
+
typeArgs.length > 0 ? `<${typeArgs.map((t) => t.getText()).join(", ")}>` : "";
|
|
126
|
+
|
|
127
|
+
if (GLOBAL_TABLE_FN_NAMES.has(fnName) && args.length >= 2) {
|
|
128
|
+
const tableArg = args[1]!;
|
|
129
|
+
if (Node.isIdentifier(tableArg) && globalTables.has(tableArg.getText())) {
|
|
130
|
+
const rest = args.slice(2).map((a) => a.getText());
|
|
131
|
+
const after = `ctx.db.global(${tableArg.getText()}).${fnName}${typeArgsText}(${rest.join(", ")})`;
|
|
132
|
+
return {
|
|
133
|
+
callExpr,
|
|
134
|
+
rule: "global-table",
|
|
135
|
+
before: callExpr.getText(),
|
|
136
|
+
after,
|
|
137
|
+
line: callExpr.getStartLineNumber(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (OWN_TENANT_FN_NAMES.has(fnName) && args.length >= 3) {
|
|
143
|
+
const objArg = args[2]!;
|
|
144
|
+
if (objectHasValidOwnTenantId(objArg)) {
|
|
145
|
+
const rest = [args[1]!, objArg, ...args.slice(3)].map((a) => a.getText());
|
|
146
|
+
const after = `ctx.db.${fnName}${typeArgsText}(${rest.join(", ")})`;
|
|
147
|
+
return {
|
|
148
|
+
callExpr,
|
|
149
|
+
rule: "own-tenant",
|
|
150
|
+
before: callExpr.getText(),
|
|
151
|
+
after,
|
|
152
|
+
line: callExpr.getStartLineNumber(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isTenantDbRawAccess(pae: Node): boolean {
|
|
161
|
+
if (!Node.isPropertyAccessExpression(pae)) return false;
|
|
162
|
+
if (pae.getName() !== "raw") return false;
|
|
163
|
+
const receiver = pae.getExpression();
|
|
164
|
+
if (receiver.getText().endsWith(".db")) return true;
|
|
165
|
+
if (Node.isIdentifier(receiver) && RAW_RECEIVER_IDENTIFIERS.has(receiver.getText())) return true;
|
|
166
|
+
if (
|
|
167
|
+
Node.isCallExpression(receiver) &&
|
|
168
|
+
receiver.getExpression().getText().startsWith("ctx.systemDb.")
|
|
169
|
+
)
|
|
170
|
+
return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function enclosingName(node: Node): string {
|
|
175
|
+
let current: Node | undefined = node.getParent();
|
|
176
|
+
while (current) {
|
|
177
|
+
if (Node.isFunctionDeclaration(current) && current.getName()) return current.getName()!;
|
|
178
|
+
if (Node.isMethodDeclaration(current)) return current.getName();
|
|
179
|
+
if (Node.isPropertyAssignment(current)) return current.getName();
|
|
180
|
+
if (Node.isVariableDeclaration(current)) return current.getName();
|
|
181
|
+
current = current.getParent();
|
|
182
|
+
}
|
|
183
|
+
return "<module>";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function truncate(text: string, limit: number): string {
|
|
187
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
188
|
+
return collapsed.length > limit ? `${collapsed.slice(0, limit)}…` : collapsed;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function directArgumentCall(pae: Node): Node | undefined {
|
|
192
|
+
const parent = pae.getParent();
|
|
193
|
+
if (parent && Node.isCallExpression(parent) && parent.getArguments().some((a) => a === pae)) {
|
|
194
|
+
return parent;
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function collectManualSite(pae: Node, fileName: string): ManualSite {
|
|
200
|
+
const reportNode = directArgumentCall(pae) ?? pae;
|
|
201
|
+
return {
|
|
202
|
+
file: fileName,
|
|
203
|
+
line: reportNode.getStartLineNumber(),
|
|
204
|
+
enclosing: enclosingName(pae),
|
|
205
|
+
text: truncate(reportNode.getText(), MANUAL_TEXT_LIMIT),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isPropertyAccessName(id: Node): boolean {
|
|
210
|
+
const parent = id.getParent();
|
|
211
|
+
return Node.isPropertyAccessExpression(parent) && parent.getNameNode() === id;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pruneUnusedDbImports(sourceFile: SourceFile): void {
|
|
215
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
216
|
+
for (const spec of [...importDecl.getNamedImports()]) {
|
|
217
|
+
if (!PRUNABLE_FN_NAMES.has(spec.getName())) continue;
|
|
218
|
+
const localName = spec.getAliasNode()?.getText() ?? spec.getName();
|
|
219
|
+
const nameNode = spec.getAliasNode() ?? spec.getNameNode();
|
|
220
|
+
const stillUsed = sourceFile
|
|
221
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
222
|
+
.some((id) => id !== nameNode && id.getText() === localName && !isPropertyAccessName(id));
|
|
223
|
+
if (!stillUsed) spec.remove();
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
importDecl.getNamedImports().length === 0 &&
|
|
227
|
+
!importDecl.getDefaultImport() &&
|
|
228
|
+
!importDecl.getNamespaceImport()
|
|
229
|
+
) {
|
|
230
|
+
importDecl.remove();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function migrateDbRawSource(
|
|
236
|
+
source: string,
|
|
237
|
+
fileName: string,
|
|
238
|
+
opts: { globalTables: ReadonlySet<string> },
|
|
239
|
+
): MigrateDbRawResult {
|
|
240
|
+
const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
|
|
241
|
+
const sourceFile = project.createSourceFile(fileName, source);
|
|
242
|
+
|
|
243
|
+
const pending: PendingRewrite[] = [];
|
|
244
|
+
for (const callExpr of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
245
|
+
const plan = planRewrite(callExpr, opts.globalTables);
|
|
246
|
+
if (plan) pending.push(plan);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const rewrites: Rewrite[] = [];
|
|
250
|
+
for (const plan of pending) {
|
|
251
|
+
if (plan.callExpr.wasForgotten()) continue;
|
|
252
|
+
plan.callExpr.replaceWithText(plan.after);
|
|
253
|
+
rewrites.push({
|
|
254
|
+
file: fileName,
|
|
255
|
+
line: plan.line,
|
|
256
|
+
rule: plan.rule,
|
|
257
|
+
before: plan.before,
|
|
258
|
+
after: plan.after,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pruneUnusedDbImports(sourceFile);
|
|
263
|
+
|
|
264
|
+
const manual: ManualSite[] = [];
|
|
265
|
+
for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
266
|
+
if (isTenantDbRawAccess(pae)) manual.push(collectManualSite(pae, fileName));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { output: sourceFile.getFullText(), rewrites, manual };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
class CliError extends Error {}
|
|
273
|
+
|
|
274
|
+
interface CliOptions {
|
|
275
|
+
readonly dryRun: boolean;
|
|
276
|
+
readonly globalTables: ReadonlySet<string>;
|
|
277
|
+
readonly paths: string[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseArgs(argv: string[]): CliOptions {
|
|
281
|
+
let dryRun = false;
|
|
282
|
+
let globalTables: ReadonlySet<string> = DEFAULT_GLOBAL_TABLES;
|
|
283
|
+
const paths: string[] = [];
|
|
284
|
+
|
|
285
|
+
for (let i = 0; i < argv.length; i++) {
|
|
286
|
+
const arg = argv[i]!;
|
|
287
|
+
if (arg === "--dry-run") {
|
|
288
|
+
dryRun = true;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (arg === "--global-tables") {
|
|
292
|
+
const value = argv[++i];
|
|
293
|
+
if (!value) throw new CliError("--global-tables requires a comma-separated value");
|
|
294
|
+
globalTables = new Set(
|
|
295
|
+
value
|
|
296
|
+
.split(",")
|
|
297
|
+
.map((s) => s.trim())
|
|
298
|
+
.filter((s) => s.length > 0),
|
|
299
|
+
);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
|
|
303
|
+
paths.push(arg);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (paths.length === 0) throw new CliError("at least one path is required");
|
|
307
|
+
return { dryRun, globalTables, paths };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isMigratableFile(name: string): boolean {
|
|
311
|
+
if (!/\.(ts|tsx)$/.test(name)) return false;
|
|
312
|
+
if (name.endsWith(".test.ts") || name.endsWith(".test.tsx")) return false;
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function walkDir(dir: string, out: string[]): void {
|
|
317
|
+
for (const name of readdirSync(dir)) {
|
|
318
|
+
if (name === "node_modules" || name === "__tests__") continue;
|
|
319
|
+
const full = join(dir, name);
|
|
320
|
+
const stat = statSync(full);
|
|
321
|
+
if (stat.isDirectory()) {
|
|
322
|
+
walkDir(full, out);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (isMigratableFile(name)) out.push(full);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function collectFiles(paths: string[]): string[] {
|
|
330
|
+
const files: string[] = [];
|
|
331
|
+
for (const p of paths) {
|
|
332
|
+
const abs = resolve(p);
|
|
333
|
+
const stat = statSync(abs);
|
|
334
|
+
if (stat.isDirectory()) walkDir(abs, files);
|
|
335
|
+
else if (isMigratableFile(abs)) files.push(abs);
|
|
336
|
+
}
|
|
337
|
+
return files;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function main(): Promise<void> {
|
|
341
|
+
let options: CliOptions;
|
|
342
|
+
try {
|
|
343
|
+
options = parseArgs(process.argv.slice(2));
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err instanceof CliError) {
|
|
346
|
+
console.error(`[migrate-db-raw] ${err.message}`);
|
|
347
|
+
process.exit(2);
|
|
348
|
+
}
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const files = collectFiles(options.paths);
|
|
353
|
+
const allRewrites: Rewrite[] = [];
|
|
354
|
+
const allManual: ManualSite[] = [];
|
|
355
|
+
let filesChanged = 0;
|
|
356
|
+
|
|
357
|
+
for (const file of files) {
|
|
358
|
+
const before = readFileSync(file, "utf8");
|
|
359
|
+
const result = migrateDbRawSource(before, file, { globalTables: options.globalTables });
|
|
360
|
+
allRewrites.push(...result.rewrites);
|
|
361
|
+
allManual.push(...result.manual);
|
|
362
|
+
|
|
363
|
+
if (result.rewrites.length === 0) continue;
|
|
364
|
+
filesChanged += 1;
|
|
365
|
+
|
|
366
|
+
if (options.dryRun) {
|
|
367
|
+
console.log(`\n=== ${file} (${result.rewrites.length} rewrite(s)) ===`);
|
|
368
|
+
for (const r of result.rewrites) {
|
|
369
|
+
console.log(`${file}:${r.line} [${r.rule}]`);
|
|
370
|
+
console.log(`- ${r.before}`);
|
|
371
|
+
console.log(`+ ${r.after}`);
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
writeFileSync(file, result.output);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const globalCount = allRewrites.filter((r) => r.rule === "global-table").length;
|
|
379
|
+
const ownTenantCount = allRewrites.filter((r) => r.rule === "own-tenant").length;
|
|
380
|
+
console.log(
|
|
381
|
+
`\n[migrate-db-raw] ${options.dryRun ? "would change" : "changed"} ${filesChanged} file(s); ` +
|
|
382
|
+
`rewrites: global-table=${globalCount}, own-tenant=${ownTenantCount}`,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if (allManual.length > 0) {
|
|
386
|
+
console.log(`\n[migrate-db-raw] manual review needed (${allManual.length}):`);
|
|
387
|
+
for (const m of allManual) {
|
|
388
|
+
console.log(`${m.file}:${m.line} ${m.enclosing} ${m.text}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (import.meta.main) {
|
|
396
|
+
await main();
|
|
397
|
+
}
|