@ultimat3/cli 22.15.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The one lcov reader: what `bun test --coverage --coverage-reporter=lcov` wrote, as per-file
|
|
2
|
+
// facts, and how the facts of several test processes add up. `scripts/coverage-gate.ts` (the
|
|
3
|
+
// framework's packages) and `coverage-floor.ts` (an app) both read through it.
|
|
4
|
+
|
|
5
|
+
/** One `SF:` record, as written: its own totals and every `DA:` line. */
|
|
6
|
+
export interface LcovRecord {
|
|
7
|
+
/** The `SF:` path, verbatim — Bun writes it relative to the directory the run started in. */
|
|
8
|
+
readonly file: string;
|
|
9
|
+
/** `LF` / `LH` / `FNF` / `FNH`. Bun's lcov carries no `FN:` lines, so functions are counts only. */
|
|
10
|
+
readonly linesFound: number;
|
|
11
|
+
readonly linesHit: number;
|
|
12
|
+
readonly funcsFound: number;
|
|
13
|
+
readonly funcsHit: number;
|
|
14
|
+
/** line → hits. */
|
|
15
|
+
readonly lines: ReadonlyMap<number, number>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseLcov(text: string): readonly LcovRecord[] {
|
|
19
|
+
const records: LcovRecord[] = [];
|
|
20
|
+
let file: string | undefined;
|
|
21
|
+
let lf = 0;
|
|
22
|
+
let lh = 0;
|
|
23
|
+
let fnf = 0;
|
|
24
|
+
let fnh = 0;
|
|
25
|
+
let lines = new Map<number, number>();
|
|
26
|
+
for (const line of text.split('\n')) {
|
|
27
|
+
if (line.startsWith('SF:')) {
|
|
28
|
+
file = line.slice(3);
|
|
29
|
+
lf = 0;
|
|
30
|
+
lh = 0;
|
|
31
|
+
fnf = 0;
|
|
32
|
+
fnh = 0;
|
|
33
|
+
lines = new Map();
|
|
34
|
+
} else if (file === undefined) {
|
|
35
|
+
// Before the first record (`TN:`), or between two.
|
|
36
|
+
} else if (line.startsWith('DA:')) {
|
|
37
|
+
const comma = line.indexOf(',');
|
|
38
|
+
const at = Number(line.slice(3, comma));
|
|
39
|
+
const hits = Number(line.slice(comma + 1));
|
|
40
|
+
if (comma > 3 && Number.isSafeInteger(at) && Number.isFinite(hits)) lines.set(at, hits);
|
|
41
|
+
} else if (line.startsWith('LF:')) lf = Number(line.slice(3));
|
|
42
|
+
else if (line.startsWith('LH:')) lh = Number(line.slice(3));
|
|
43
|
+
else if (line.startsWith('FNF:')) fnf = Number(line.slice(4));
|
|
44
|
+
else if (line.startsWith('FNH:')) fnh = Number(line.slice(4));
|
|
45
|
+
else if (line === 'end_of_record') {
|
|
46
|
+
records.push({
|
|
47
|
+
file,
|
|
48
|
+
linesFound: lf,
|
|
49
|
+
linesHit: lh,
|
|
50
|
+
funcsFound: fnf,
|
|
51
|
+
funcsHit: fnh,
|
|
52
|
+
lines,
|
|
53
|
+
});
|
|
54
|
+
file = undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return records;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One source file's coverage: which lines ran, which could have and did not, and its functions. */
|
|
61
|
+
export interface FileCoverage {
|
|
62
|
+
readonly hit: readonly number[];
|
|
63
|
+
readonly miss: readonly number[];
|
|
64
|
+
readonly funcsFound: number;
|
|
65
|
+
readonly funcsHit: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const fileCoverageOf = (record: LcovRecord): FileCoverage => {
|
|
69
|
+
const hit: number[] = [];
|
|
70
|
+
const miss: number[] = [];
|
|
71
|
+
for (const [line, hits] of record.lines) (hits > 0 ? hit : miss).push(line);
|
|
72
|
+
return { hit, miss, funcsFound: record.funcsFound, funcsHit: record.funcsHit };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One file's coverage across several test PROCESSES.
|
|
77
|
+
*
|
|
78
|
+
* A line is covered when any process ran it. It is uncovered only when EVERY process that loaded
|
|
79
|
+
* the file lists it as unrun — because Bun lists a function nothing called in that process by its
|
|
80
|
+
* whole line range, comments and blank lines included, and lists a function that ran by its
|
|
81
|
+
* executable lines alone. A union therefore counts the comments of every function one process
|
|
82
|
+
* called and another merely loaded, and the number moves with how the files were dealt:
|
|
83
|
+
* `examples/dummy` read 59.9% at four workers, 65.0% at two and 65.5% in one process, same tree
|
|
84
|
+
* (measured 2026-10-01, Bun 1.4.0, whose own `--parallel` merge is that union). This rule read
|
|
85
|
+
* 65.90% for 2, 4 and 8 processes and for contiguous chunks — and is exactly what Bun reports for
|
|
86
|
+
* one `--isolate` process.
|
|
87
|
+
*
|
|
88
|
+
* Functions have no identity in Bun's lcov, only two counts, so they cannot be unioned: the file
|
|
89
|
+
* takes the best any one process reached. That is a lower bound, and the reason the processes are
|
|
90
|
+
* cut along directories (`verify-coverage-run.ts`) — the tests beside a file run together.
|
|
91
|
+
*/
|
|
92
|
+
export function mergeFileCoverage(parts: readonly FileCoverage[]): FileCoverage {
|
|
93
|
+
if (parts.length === 1) return parts[0] as FileCoverage;
|
|
94
|
+
const hit = new Set<number>();
|
|
95
|
+
for (const part of parts) for (const line of part.hit) hit.add(line);
|
|
96
|
+
const missIn = parts.map((part) => new Set(part.miss));
|
|
97
|
+
const miss = [...(missIn[0] ?? [])].filter(
|
|
98
|
+
(line) => !hit.has(line) && missIn.every((set) => set.has(line)),
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
hit: [...hit].sort((a, b) => a - b),
|
|
102
|
+
miss: miss.sort((a, b) => a - b),
|
|
103
|
+
funcsFound: Math.max(0, ...parts.map((part) => part.funcsFound)),
|
|
104
|
+
funcsHit: Math.max(0, ...parts.map((part) => part.funcsHit)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** file → coverage. A plain record so it survives `--json` between a shard and `x verify merge`. */
|
|
109
|
+
export type CoverageMap = Readonly<Record<string, FileCoverage>>;
|
|
110
|
+
|
|
111
|
+
/** Several processes' (or several shards') maps as one, file by file. */
|
|
112
|
+
export function mergeCoverage(maps: readonly CoverageMap[]): CoverageMap {
|
|
113
|
+
const byFile = new Map<string, FileCoverage[]>();
|
|
114
|
+
for (const map of maps) {
|
|
115
|
+
for (const [file, coverage] of Object.entries(map)) {
|
|
116
|
+
const list = byFile.get(file) ?? [];
|
|
117
|
+
list.push(coverage);
|
|
118
|
+
byFile.set(file, list);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
[...byFile]
|
|
123
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
124
|
+
.map(([file, parts]) => [file, mergeFileCoverage(parts)]),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `hit / found` as a percentage to two decimals; nothing found is 0, never a pass over nothing. */
|
|
129
|
+
export const percent = (hit: number, found: number): number =>
|
|
130
|
+
found === 0 ? 0 : Math.round((hit / found) * 10_000) / 100;
|
|
131
|
+
|
|
132
|
+
/** `[1,2,3,7]` → `"1-3,7"`: a shard's facts ride in its `--json` document, so they are kept small. */
|
|
133
|
+
export function encodeLines(lines: readonly number[]): string {
|
|
134
|
+
const sorted = [...lines].sort((a, b) => a - b);
|
|
135
|
+
const runs: string[] = [];
|
|
136
|
+
let start: number | undefined;
|
|
137
|
+
let last = 0;
|
|
138
|
+
const flush = (): void => {
|
|
139
|
+
if (start !== undefined) runs.push(start === last ? String(start) : `${start}-${last}`);
|
|
140
|
+
};
|
|
141
|
+
for (const line of sorted) {
|
|
142
|
+
if (start !== undefined && line === last + 1) {
|
|
143
|
+
last = line;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
flush();
|
|
147
|
+
start = line;
|
|
148
|
+
last = line;
|
|
149
|
+
}
|
|
150
|
+
flush();
|
|
151
|
+
return runs.join(',');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The inverse of `encodeLines`; a run that does not parse is no lines, never a throw. */
|
|
155
|
+
export function decodeLines(text: string): readonly number[] {
|
|
156
|
+
const lines: number[] = [];
|
|
157
|
+
for (const run of text.split(',')) {
|
|
158
|
+
const match = /^(\d+)(?:-(\d+))?$/.exec(run);
|
|
159
|
+
if (match === null) continue;
|
|
160
|
+
const from = Number(match[1]);
|
|
161
|
+
const to = match[2] === undefined ? from : Number(match[2]);
|
|
162
|
+
for (let line = from; line <= to && line - from < 1_000_000; line += 1) lines.push(line);
|
|
163
|
+
}
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A file's facts as they cross `--json`: hit runs, miss runs, `[funcsFound, funcsHit]`. */
|
|
168
|
+
export type CoverageWire = Readonly<
|
|
169
|
+
Record<string, { readonly h: string; readonly m: string; readonly f: readonly [number, number] }>
|
|
170
|
+
>;
|
|
171
|
+
|
|
172
|
+
export const encodeCoverage = (map: CoverageMap): CoverageWire =>
|
|
173
|
+
Object.fromEntries(
|
|
174
|
+
Object.entries(map).map(([file, one]) => [
|
|
175
|
+
file,
|
|
176
|
+
{ h: encodeLines(one.hit), m: encodeLines(one.miss), f: [one.funcsFound, one.funcsHit] },
|
|
177
|
+
]),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
/** A part's `data.coverage.<step>` back into a map, or nothing when it is not one. */
|
|
181
|
+
export function decodeCoverage(value: unknown): CoverageMap | undefined {
|
|
182
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
183
|
+
const out = new Map<string, FileCoverage>();
|
|
184
|
+
for (const [file, raw] of Object.entries(value)) {
|
|
185
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
186
|
+
const { h, m, f } = raw as { h?: unknown; m?: unknown; f?: unknown };
|
|
187
|
+
if (typeof h !== 'string' || typeof m !== 'string' || !Array.isArray(f)) return undefined;
|
|
188
|
+
const [found, hit] = f as unknown[];
|
|
189
|
+
if (typeof found !== 'number' || typeof hit !== 'number') return undefined;
|
|
190
|
+
out.set(file, { hit: decodeLines(h), miss: decodeLines(m), funcsFound: found, funcsHit: hit });
|
|
191
|
+
}
|
|
192
|
+
return Object.fromEntries(out);
|
|
193
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// What counts as an app's OWN source for the coverage floor, and what a file no test loaded
|
|
2
|
+
// weighs. Bun writes an lcov record only for a file something imported, so a module nothing
|
|
3
|
+
// reaches is absent from both halves of the fraction — and makes the number BETTER.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path-joining primitive; `join` builds the host path to each source file.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { maskLiterals, stripComments } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/** A test file, whatever its suite: `*.test.ts`, `*.contract.test.tsx`, … */
|
|
10
|
+
export const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
|
|
11
|
+
|
|
12
|
+
/** Index just past the balanced `{ … }` that starts at or after `from`. */
|
|
13
|
+
function skipBalanced(text: string, from: number): number {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = from; i < text.length; i += 1) {
|
|
16
|
+
const ch = text[i];
|
|
17
|
+
if (ch === '{') depth += 1;
|
|
18
|
+
else if (ch === '}') {
|
|
19
|
+
depth -= 1;
|
|
20
|
+
if (depth === 0) return i + 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return text.length;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Index just past the `;` that ends a `type` alias — the first one at nesting depth zero.
|
|
28
|
+
*
|
|
29
|
+
* Depth matters and a naive scan gets it wrong: `type _A = Assert<[FactKeysOf<{ a: 1 }>] extends
|
|
30
|
+
* [Actor] ? true : false>;` contains a `{ … }` whose closing brace is NOT the end of the
|
|
31
|
+
* declaration, and matching braces there leaves `, ] extends [Actor] … >;` behind, which then
|
|
32
|
+
* reads as executable code.
|
|
33
|
+
*/
|
|
34
|
+
function endOfTypeAlias(text: string, from: number): number {
|
|
35
|
+
let depth = 0;
|
|
36
|
+
for (let i = from; i < text.length; i += 1) {
|
|
37
|
+
const ch = text[i];
|
|
38
|
+
if (ch === '{' || ch === '[' || ch === '(' || ch === '<') depth += 1;
|
|
39
|
+
else if (ch === '}' || ch === ']' || ch === ')' || ch === '>') depth -= 1;
|
|
40
|
+
else if (ch === ';' && depth <= 0) return i + 1;
|
|
41
|
+
}
|
|
42
|
+
return text.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a file emits anything at RUNTIME, as opposed to a barrel or a types-only module.
|
|
47
|
+
*
|
|
48
|
+
* Load-bearing, not cosmetic: bun writes an lcov record only for a file that produced executable
|
|
49
|
+
* code, so a pure barrel and a types-only module are legitimately absent — while a real module
|
|
50
|
+
* nothing imported is absent for the opposite reason and must be reported.
|
|
51
|
+
* `packages/core/src/type-pins.ts` says it of itself: "This module emits nothing and exports
|
|
52
|
+
* nothing anybody imports."
|
|
53
|
+
*
|
|
54
|
+
* A regex cannot decide this. `metrics-types.ts` contains `(() => number)` in a TYPE position, so
|
|
55
|
+
* scanning for `=>` calls a types-only file executable. Hence a scanner: strip comments and
|
|
56
|
+
* re-export statements, then remove `interface` blocks by brace matching and `type` aliases to
|
|
57
|
+
* their depth-zero `;`, and ask whether anything is left.
|
|
58
|
+
*
|
|
59
|
+
* `declare module` and `declare global` are removed the same way, and for the same reason the
|
|
60
|
+
* `declare const` line exists: an ambient block emits nothing at runtime, so bun writes no lcov
|
|
61
|
+
* record for a file built only from one. Their bodies go with them — an augmentation's members
|
|
62
|
+
* are declarations whatever they look like. Without this, `matcher-surface.ts` (a
|
|
63
|
+
* `declare module 'bun:test'` and nothing else) read as executable and was reported as a real
|
|
64
|
+
* module no test imports, which is the opposite of what it is.
|
|
65
|
+
*/
|
|
66
|
+
export function hasExecutableCode(source: string): boolean {
|
|
67
|
+
let text = source
|
|
68
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
69
|
+
.replace(/^[ \t]*\/\/.*$/gm, '')
|
|
70
|
+
.replace(/^[ \t]*(?:import|export)\b[^;]*?from\s*'[^']*';[ \t]*$/gm, '')
|
|
71
|
+
.replace(/^[ \t]*import\s+type\b[^;]*;[ \t]*$/gm, '')
|
|
72
|
+
// `declare const x: T;` is ambient — it emits nothing, and `type-pins.ts` files are built
|
|
73
|
+
// almost entirely from them.
|
|
74
|
+
.replace(/^[ \t]*declare\s+(?:const|let|var|function|class)\b[^;]*;[ \t]*$/gm, '');
|
|
75
|
+
|
|
76
|
+
// Ambient blocks first: their bodies hold `interface` members that the loop below would strip
|
|
77
|
+
// individually, leaving a bare `declare module '…' { }` shell behind that reads as code.
|
|
78
|
+
for (;;) {
|
|
79
|
+
const ambient = /(?:^|\n)[ \t]*declare\s+(?:module\s+'[^']*'|global)\s*\{/.exec(text);
|
|
80
|
+
if (ambient === null) break;
|
|
81
|
+
const start = ambient.index + (ambient[0].startsWith('\n') ? 1 : 0);
|
|
82
|
+
text = text.slice(0, start) + text.slice(skipBalanced(text, text.indexOf('{', start)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (;;) {
|
|
86
|
+
const found = /(?:^|\n)[ \t]*(?:export\s+)?(?:declare\s+)?(interface|type)\s/.exec(text);
|
|
87
|
+
if (found === null) break;
|
|
88
|
+
const start = found.index + (found[0].startsWith('\n') ? 1 : 0);
|
|
89
|
+
const end =
|
|
90
|
+
found[1] === 'interface'
|
|
91
|
+
? skipBalanced(text, text.indexOf('{', start))
|
|
92
|
+
: endOfTypeAlias(text, start);
|
|
93
|
+
text = text.slice(0, start) + text.slice(end);
|
|
94
|
+
}
|
|
95
|
+
return text.replace(/\s/g, '') !== '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What a file with NO lcov record weighs, read off its source because nothing else knows: its
|
|
100
|
+
* non-blank, non-comment lines, and one function per `function` keyword or arrow (at least one —
|
|
101
|
+
* it has executable code, or it would not be asked). An estimate, deliberately on the heavy side
|
|
102
|
+
* of what Bun reports for a loaded file (1.34x over `examples/dummy`'s loaded sources,
|
|
103
|
+
* 2026-10-01): the only way to make it exact is to load the file from a test, which is the fix.
|
|
104
|
+
*/
|
|
105
|
+
export function unloadedWeight(source: string): { readonly lines: number; readonly funcs: number } {
|
|
106
|
+
const stripped = stripComments(source);
|
|
107
|
+
const lines = stripped.split('\n').filter((line) => line.trim() !== '').length;
|
|
108
|
+
const funcs = (maskLiterals(stripped).match(/\bfunction\b|=>/g) ?? []).length;
|
|
109
|
+
return { lines: Math.max(1, lines), funcs: Math.max(1, funcs) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Never an app's own source, wherever it sits. */
|
|
113
|
+
const NOT_SOURCE = /(?:^|\/)(?:node_modules|dist|build|\.x)\//;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every `.ts` / `.tsx` under the app's `apps/` and `packages/` that is its own source: not a
|
|
117
|
+
* test, not a declaration file, not installed or built output, and not excluded by `exclude`
|
|
118
|
+
* (globs relative to the app root). Sorted, so two runs of one tree read one list.
|
|
119
|
+
*/
|
|
120
|
+
export function appSourceFiles(root: string, exclude: readonly string[] = []): readonly string[] {
|
|
121
|
+
const globs = exclude.map((pattern) => new Bun.Glob(pattern));
|
|
122
|
+
const files: string[] = [];
|
|
123
|
+
for (const rel of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scanSync({ cwd: root })) {
|
|
124
|
+
const path = rel.split('\\').join('/');
|
|
125
|
+
if (NOT_SOURCE.test(path) || TEST_FILE.test(path) || path.endsWith('.d.ts')) continue;
|
|
126
|
+
if (globs.some((glob) => glob.match(path))) continue;
|
|
127
|
+
files.push(path);
|
|
128
|
+
}
|
|
129
|
+
return files.sort();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The source text of `rel` under `root`, or nothing when it cannot be read. */
|
|
133
|
+
export const readSource = (root: string, rel: string): Promise<string | undefined> =>
|
|
134
|
+
Bun.file(join(root, rel))
|
|
135
|
+
.text()
|
|
136
|
+
.catch(() => undefined);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// `x db migrate`'s second look at the database it just migrated: does it hold a trigger, function,
|
|
2
|
+
// view, type or sequence that replaying the migrations does not create? `runMigrations` compares
|
|
3
|
+
// tables against the snapshot and closes its connection; a snapshot records only what entities
|
|
4
|
+
// declare, so everything else is asked here, against the catalog the schema dump was rendered from.
|
|
5
|
+
|
|
6
|
+
import type { DriftDifference } from '@ultimat3/db';
|
|
7
|
+
import type { CatalogDescription } from '@ultimat3/db/schema-dump';
|
|
8
|
+
import { introspectCatalog, unexpectedObjects } from '@ultimat3/db/schema-dump';
|
|
9
|
+
import { resolveServices } from './runtime-bindings';
|
|
10
|
+
import { startQueue } from './runtime-queue';
|
|
11
|
+
|
|
12
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The same boot `runMigrations` used, reopened: that function is `serve.ts`'s and is deliberately
|
|
16
|
+
* not wrapped, so the connection it held is gone by the time its report is in hand. Reopening an
|
|
17
|
+
* embedded database is a fraction of a second; `x db migrate` is not a hot path.
|
|
18
|
+
*/
|
|
19
|
+
export async function liveObjectDrift(
|
|
20
|
+
root: string,
|
|
21
|
+
env: Env,
|
|
22
|
+
expected: CatalogDescription,
|
|
23
|
+
): Promise<readonly DriftDifference[]> {
|
|
24
|
+
const queue = await startQueue(resolveServices(root, env), undefined, env);
|
|
25
|
+
try {
|
|
26
|
+
return unexpectedObjects(await introspectCatalog({ client: queue.db }), expected);
|
|
27
|
+
} finally {
|
|
28
|
+
await queue.stop();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Which database the schema dump is replayed on, and that database, opened and thrown away. ONE
|
|
2
|
+
// rule: the embedded database, unless the migrations create an extension it cannot link — then a
|
|
3
|
+
// real Postgres, in a database this module creates and drops. Decided from the migrations and the
|
|
4
|
+
// installed PGlite alone, never from which URL happens to be set, so a laptop and CI choose alike.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path joiner.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { DbClient, LinkedExtensions, Migration } from '@ultimat3/db';
|
|
9
|
+
import {
|
|
10
|
+
createPgliteClient,
|
|
11
|
+
createPostgresClient,
|
|
12
|
+
DbError,
|
|
13
|
+
linkPgliteExtensions,
|
|
14
|
+
raw,
|
|
15
|
+
} from '@ultimat3/db';
|
|
16
|
+
import { migrationExtensions } from './migration-extensions';
|
|
17
|
+
|
|
18
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a real Postgres is looked for, in order — the admin URL `@ultimat3/testing` already
|
|
22
|
+
* clones its template databases from (`template-db.ts`), so a CI that runs the `live` suite has
|
|
23
|
+
* it set and nothing new to configure.
|
|
24
|
+
*/
|
|
25
|
+
export const REPLAY_URL_ENVS = ['TEST_DATABASE_URL', 'DATABASE_URL'] as const;
|
|
26
|
+
|
|
27
|
+
export type ReplayEngine =
|
|
28
|
+
| { readonly kind: 'embedded'; readonly extensions: readonly string[] }
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'postgres';
|
|
31
|
+
readonly adminUrl: string;
|
|
32
|
+
/** The extensions that forced it, for the finding a missing URL becomes. */
|
|
33
|
+
readonly requires: readonly string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The migrations need a server and none is named. `X_SCHEMA_DUMP_DRIFT`, because the dump cannot
|
|
38
|
+
* be produced or held; the cause names the extension, the fix the variable and the command.
|
|
39
|
+
*/
|
|
40
|
+
export function replayServerMissing(requires: readonly string[]): DbError {
|
|
41
|
+
const named = requires.map((name) => `"${name}"`).join(', ');
|
|
42
|
+
return new DbError({
|
|
43
|
+
code: 'X_SCHEMA_DUMP_DRIFT',
|
|
44
|
+
cause: `the migrations create extension ${named}, which the embedded database cannot link, so the schema dump is replayed on a real Postgres — and neither ${REPLAY_URL_ENVS.join(' nor ')} names one`,
|
|
45
|
+
fix: `${REPLAY_URL_ENVS[0]}=postgres://<user>:<password>@localhost:5432/postgres x db gen # a server with that extension installed, and a role that may create a database`,
|
|
46
|
+
meta: { kind: 'engine-unavailable', extensions: [...requires] },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ExtensionLinker = (names: readonly string[]) => Promise<LinkedExtensions>;
|
|
51
|
+
|
|
52
|
+
/** The rule. Throws `replayServerMissing` when it selects a server nobody named. */
|
|
53
|
+
export async function chooseReplayEngine(
|
|
54
|
+
migrations: readonly Migration[],
|
|
55
|
+
env: Env,
|
|
56
|
+
link: ExtensionLinker = linkPgliteExtensions,
|
|
57
|
+
): Promise<ReplayEngine> {
|
|
58
|
+
const extensions = migrationExtensions(migrations);
|
|
59
|
+
const { missing } = await link(extensions);
|
|
60
|
+
if (missing.length === 0) return { kind: 'embedded', extensions };
|
|
61
|
+
const adminUrl = REPLAY_URL_ENVS.map((name) => env[name]).find(
|
|
62
|
+
(value) => value !== undefined && value.length > 0,
|
|
63
|
+
);
|
|
64
|
+
if (adminUrl === undefined) throw replayServerMissing(missing);
|
|
65
|
+
return { kind: 'postgres', adminUrl, requires: missing };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Unique per call: two `x db gen` runs against one server must not share a scratch database. */
|
|
69
|
+
const scratchName = (): string => `x_schema_dump_${process.pid}_${crypto.randomUUID().slice(0, 8)}`;
|
|
70
|
+
|
|
71
|
+
async function withPostgresScratch<T>(
|
|
72
|
+
adminUrl: string,
|
|
73
|
+
work: (client: DbClient) => Promise<T>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
const database = scratchName();
|
|
76
|
+
const admin = createPostgresClient({ url: adminUrl });
|
|
77
|
+
try {
|
|
78
|
+
// `template0`: the server's `template1` may carry objects an operator put there, and they
|
|
79
|
+
// would be dumped as the app's.
|
|
80
|
+
await admin.execute(raw(`create database ${database} template template0`));
|
|
81
|
+
const target = new URL(adminUrl);
|
|
82
|
+
target.pathname = `/${database}`;
|
|
83
|
+
const client = createPostgresClient({ url: target.toString() });
|
|
84
|
+
try {
|
|
85
|
+
return await work(client);
|
|
86
|
+
} finally {
|
|
87
|
+
await client.close();
|
|
88
|
+
await admin.execute(raw(`drop database if exists ${database} with (force)`));
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
await admin.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run `work` against an empty scratch database on `engine`, and release it. The embedded one
|
|
97
|
+
* boots from the snapshot cache under `stateDir`, so only the first boot per PGlite version runs
|
|
98
|
+
* `initdb`.
|
|
99
|
+
*/
|
|
100
|
+
export async function withScratchDatabase<T>(
|
|
101
|
+
engine: ReplayEngine,
|
|
102
|
+
stateDir: string | undefined,
|
|
103
|
+
work: (client: DbClient) => Promise<T>,
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
if (engine.kind === 'postgres') return withPostgresScratch(engine.adminUrl, work);
|
|
106
|
+
const client = createPgliteClient({
|
|
107
|
+
extensions: engine.extensions,
|
|
108
|
+
...(stateDir === undefined ? {} : { snapshotDir: join(stateDir, 'cache') }),
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
return await work(client);
|
|
112
|
+
} finally {
|
|
113
|
+
await client.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The app's schema dump — `packages/db/schema/` — produced, read and written. ONE producer: a
|
|
2
|
+
// scratch database (`db-replay-engine.ts` says which) with the framework's tables applied and
|
|
3
|
+
// every migration replayed, never the developer's own database. `x db gen`, `x db migrate` and the `drift` step all call it,
|
|
4
|
+
// so the bytes the gate compares against are the bytes the commands wrote, on one engine.
|
|
5
|
+
|
|
6
|
+
// why: Bun has no directory probe — `Bun.file().exists()` answers about a file.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
// why: Bun ships no path joiner.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import type { Migration } from '@ultimat3/db';
|
|
11
|
+
import { migrate, raw } from '@ultimat3/db';
|
|
12
|
+
import type {
|
|
13
|
+
CatalogDescription,
|
|
14
|
+
SchemaDumpDifference,
|
|
15
|
+
SchemaDumpFile,
|
|
16
|
+
} from '@ultimat3/db/schema-dump';
|
|
17
|
+
import {
|
|
18
|
+
introspectCatalog,
|
|
19
|
+
loadSchemaDump,
|
|
20
|
+
reloadDifferences,
|
|
21
|
+
renderSchemaDump,
|
|
22
|
+
schemaDumpDifferenceOf,
|
|
23
|
+
} from '@ultimat3/db/schema-dump';
|
|
24
|
+
import { chooseReplayEngine, withScratchDatabase } from './db-replay-engine';
|
|
25
|
+
import { applyFrameworkSchema } from './framework-schema';
|
|
26
|
+
import { resolveServices } from './runtime-bindings';
|
|
27
|
+
|
|
28
|
+
/** Where the dump lives. App-root-relative, POSIX — beside `MIGRATIONS_DIR`, its one source. */
|
|
29
|
+
export const SCHEMA_DUMP_DIR = 'packages/db/schema';
|
|
30
|
+
|
|
31
|
+
export interface ReplayedSchema {
|
|
32
|
+
readonly catalog: CatalogDescription;
|
|
33
|
+
readonly files: readonly SchemaDumpFile[];
|
|
34
|
+
/** Empty when a database loaded from `files` renders `files` again. Always empty without `reload`. */
|
|
35
|
+
readonly reload: readonly SchemaDumpDifference[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReplayOptions {
|
|
39
|
+
/** Also prove load equals replay: reset the schema, load the dump, render it again. */
|
|
40
|
+
readonly reload?: boolean | undefined;
|
|
41
|
+
/** The app's state directory (`.x/`), where the embedded scratch boot keeps its snapshot. */
|
|
42
|
+
readonly stateDir?: string | undefined;
|
|
43
|
+
/** Read for the server URL when the migrations cannot replay on the embedded database. */
|
|
44
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Framework tables first, then every migration — the order every real boot runs them in
|
|
49
|
+
* (`startQueue`, then `migrate`), so a migration's foreign key into `x_users` finds its target.
|
|
50
|
+
* The client is handed to each call and never installed as the ambient `db()`: this runs inside
|
|
51
|
+
* `x verify`, beside steps that own that accessor.
|
|
52
|
+
*
|
|
53
|
+
* The reload reuses the SAME database, emptied — a second scratch database is a second boot for a
|
|
54
|
+
* question `drop schema … cascade` answers as well. It is asked only of a dump that claims to be
|
|
55
|
+
* whole: one that lists objects in `unrendered.sql` has already said a database loaded from it is
|
|
56
|
+
* not the migrated one.
|
|
57
|
+
*/
|
|
58
|
+
export async function replaySchema(
|
|
59
|
+
migrations: readonly Migration[],
|
|
60
|
+
options: ReplayOptions = {},
|
|
61
|
+
): Promise<ReplayedSchema> {
|
|
62
|
+
const engine = await chooseReplayEngine(migrations, options.env ?? Bun.env);
|
|
63
|
+
return withScratchDatabase(engine, options.stateDir, async (client) => {
|
|
64
|
+
await applyFrameworkSchema((statement) => client.execute(raw(statement)));
|
|
65
|
+
await migrate({ migrations, client });
|
|
66
|
+
const catalog = await introspectCatalog({ client });
|
|
67
|
+
const files = renderSchemaDump(catalog);
|
|
68
|
+
if (options.reload !== true || catalog.unrendered.length > 0) {
|
|
69
|
+
return { catalog, files, reload: [] };
|
|
70
|
+
}
|
|
71
|
+
await client.execute(raw('drop schema public cascade'));
|
|
72
|
+
await client.execute(raw('create schema public'));
|
|
73
|
+
try {
|
|
74
|
+
await loadSchemaDump({ client, files });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A refusal to load is a verdict about the dump, not a failed replay: reported as the
|
|
77
|
+
// difference it is, so the caller's finding names the file rather than the engine.
|
|
78
|
+
const refused = schemaDumpDifferenceOf(error);
|
|
79
|
+
if (refused === undefined) throw error;
|
|
80
|
+
return { catalog, files, reload: [refused] };
|
|
81
|
+
}
|
|
82
|
+
const reloaded = renderSchemaDump(await introspectCatalog({ client }));
|
|
83
|
+
return { catalog, files, reload: reloadDifferences(files, reloaded) };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The replay for an app on disk: its own state directory, its command's environment. */
|
|
88
|
+
export const replayFor = (
|
|
89
|
+
root: string,
|
|
90
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
91
|
+
reload: boolean,
|
|
92
|
+
): ((migrations: readonly Migration[]) => Promise<ReplayedSchema>) => {
|
|
93
|
+
return (migrations) =>
|
|
94
|
+
replaySchema(migrations, { reload, env, stateDir: resolveServices(root, env).stateDir });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const hasSchemaDump = (root: string): boolean => existsSync(join(root, SCHEMA_DUMP_DIR));
|
|
98
|
+
|
|
99
|
+
/** Every `.sql` under the dump directory, sorted by path — the shape `renderSchemaDump` answers in. */
|
|
100
|
+
export async function readSchemaDump(root: string): Promise<readonly SchemaDumpFile[]> {
|
|
101
|
+
const dir = join(root, SCHEMA_DUMP_DIR);
|
|
102
|
+
if (!existsSync(dir)) return [];
|
|
103
|
+
const paths: string[] = [];
|
|
104
|
+
for await (const path of new Bun.Glob('**/*.sql').scan({ cwd: dir })) {
|
|
105
|
+
paths.push(path.replaceAll('\\', '/'));
|
|
106
|
+
}
|
|
107
|
+
paths.sort();
|
|
108
|
+
const files: SchemaDumpFile[] = [];
|
|
109
|
+
for (const path of paths) {
|
|
110
|
+
files.push({ path, content: await Bun.file(join(dir, path)).text() });
|
|
111
|
+
}
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SchemaDumpWrite {
|
|
116
|
+
/** App-root-relative paths whose bytes this call changed or created. */
|
|
117
|
+
readonly written: readonly string[];
|
|
118
|
+
/** Files the dump no longer produces, deleted. */
|
|
119
|
+
readonly removed: readonly string[];
|
|
120
|
+
/** Every file the dump holds after the write. */
|
|
121
|
+
readonly total: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Make the directory equal to `files`: write what differs, delete what nothing renders. A file
|
|
126
|
+
* whose bytes already match is left untouched, so an unchanged table keeps its mtime and a
|
|
127
|
+
* one-table migration is a one-file write.
|
|
128
|
+
*/
|
|
129
|
+
export async function writeSchemaDump(
|
|
130
|
+
root: string,
|
|
131
|
+
files: readonly SchemaDumpFile[],
|
|
132
|
+
): Promise<SchemaDumpWrite> {
|
|
133
|
+
const dir = join(root, SCHEMA_DUMP_DIR);
|
|
134
|
+
const held = new Map((await readSchemaDump(root)).map((file) => [file.path, file.content]));
|
|
135
|
+
const written: string[] = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
if (held.get(file.path) === file.content) continue;
|
|
138
|
+
// `createPath` is Bun's default, spelled: a kind directory may not exist yet.
|
|
139
|
+
await Bun.write(join(dir, file.path), file.content, { createPath: true });
|
|
140
|
+
written.push(`${SCHEMA_DUMP_DIR}/${file.path}`);
|
|
141
|
+
}
|
|
142
|
+
const rendered = new Set(files.map((file) => file.path));
|
|
143
|
+
const removed: string[] = [];
|
|
144
|
+
for (const path of held.keys()) {
|
|
145
|
+
if (rendered.has(path)) continue;
|
|
146
|
+
await Bun.file(join(dir, path)).delete();
|
|
147
|
+
removed.push(`${SCHEMA_DUMP_DIR}/${path}`);
|
|
148
|
+
}
|
|
149
|
+
return { written, removed, total: files.length };
|
|
150
|
+
}
|