@rehearsal-db/core 1.1.0 → 1.3.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/COMPATIBILITY.md +40 -0
  3. package/README.md +7 -3
  4. package/dist/package.json +4 -1
  5. package/dist/src/cli/arguments.d.mts +1 -0
  6. package/dist/src/cli/arguments.mjs +5 -0
  7. package/dist/src/cli/basic_command_handlers.mjs +6 -0
  8. package/dist/src/cli/cli_commands.mjs +2 -0
  9. package/dist/src/cli/editor_command_handlers.mjs +24 -0
  10. package/dist/src/cli/project_commands.d.mts +8 -0
  11. package/dist/src/cli/runtime_commands.d.mts +2 -19
  12. package/dist/src/cli/runtime_commands.mjs +3 -90
  13. package/dist/src/cli/runtime_stack_runner.d.mts +41 -0
  14. package/dist/src/cli/runtime_stack_runner.mjs +104 -0
  15. package/dist/src/cli/runtime_workload_admission.d.mts +8 -0
  16. package/dist/src/cli/runtime_workload_admission.mjs +22 -0
  17. package/dist/src/cli/source_commands.d.mts +8 -0
  18. package/dist/src/cli/workload_command_handlers.d.mts +2 -0
  19. package/dist/src/cli/workload_command_handlers.mjs +38 -0
  20. package/dist/src/project/configuration_contract.d.mts +5 -0
  21. package/dist/src/project/configuration_normalization.d.mts +4 -0
  22. package/dist/src/project/configuration_resolution.d.mts +4 -0
  23. package/dist/src/project/configuration_resource_policy.d.mts +4 -0
  24. package/dist/src/project/configuration_resource_policy.mjs +20 -1
  25. package/dist/src/project/configuration_schema.mjs +1 -0
  26. package/dist/src/runtime/migration_history.d.mts +5 -1
  27. package/dist/src/runtime/migration_history.mjs +24 -6
  28. package/dist/src/runtime/migration_impact.d.mts +125 -0
  29. package/dist/src/runtime/migration_impact.mjs +278 -0
  30. package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
  31. package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
  32. package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
  33. package/dist/src/runtime/migration_impact_plan.mjs +297 -0
  34. package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
  35. package/dist/src/runtime/migration_impact_queries.mjs +270 -0
  36. package/dist/src/runtime/migration_insights.d.mts +137 -0
  37. package/dist/src/runtime/migration_insights.mjs +143 -0
  38. package/dist/src/runtime/migration_insights_input.d.mts +13 -0
  39. package/dist/src/runtime/migration_insights_input.mjs +153 -0
  40. package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
  41. package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
  42. package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
  43. package/dist/src/runtime/migration_sql_scan.mjs +369 -0
  44. package/dist/src/runtime/plan.d.mts +2 -1
  45. package/dist/src/runtime/plan.mjs +4 -4
  46. package/dist/src/runtime/topology.d.mts +1 -1
  47. package/dist/src/runtime/topology.mjs +2 -2
  48. package/dist/src/runtime/workload_queue.d.mts +39 -0
  49. package/dist/src/runtime/workload_queue.mjs +211 -0
  50. package/dist/src/runtime/workload_queue_location.d.mts +1 -0
  51. package/dist/src/runtime/workload_queue_location.mjs +43 -0
  52. package/dist/src/runtime/workload_queue_store.d.mts +27 -0
  53. package/dist/src/runtime/workload_queue_store.mjs +182 -0
  54. package/dist/src/shared/cancellation.mjs +2 -0
  55. package/dist/src/shared/held_path_ancestry.d.mts +7 -0
  56. package/dist/src/shared/held_path_ancestry.mjs +110 -0
  57. package/dist/src/shared/operation_guard.mjs +1 -0
  58. package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
  59. package/dist/src/targets/migration_impact_runtime.mjs +367 -0
  60. package/docs/README.md +20 -7
  61. package/docs/adapters.md +10 -0
  62. package/docs/architecture.md +84 -1
  63. package/docs/commands.md +189 -34
  64. package/docs/configuration.md +56 -6
  65. package/docs/getting-started.md +1 -1
  66. package/docs/index.md +1 -0
  67. package/docs/releasing.md +15 -9
  68. package/docs/roadmap.md +27 -6
  69. package/docs/runtime-lifecycle.md +10 -0
  70. package/docs/stability.md +4 -4
  71. package/docs/updating.md +73 -0
  72. package/docs/vscode.md +23 -17
  73. package/package.json +4 -1
@@ -0,0 +1,153 @@
1
+ /** Exact, bounded project-local SQL input. Contents never enter public findings. */
2
+ import { constants } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { lstat, open, realpath } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { readMigrationFileInventory } from "./migration_history.mjs";
8
+ import { RehearsalError } from "../shared/diagnostics.mjs";
9
+ import { holdPathAncestry, } from "../shared/held_path_ancestry.mjs";
10
+ export const MIGRATION_INSIGHTS_LIMITS = Object.freeze({
11
+ maximumFiles: 10000,
12
+ maximumDirectoryEntries: 10000,
13
+ maximumCandidates: 1000,
14
+ maximumFileBytes: 2 * 1024 * 1024,
15
+ maximumTotalBytes: 16 * 1024 * 1024,
16
+ maximumFindings: 5000,
17
+ });
18
+ const inside = (root, path) => {
19
+ const value = relative(root, path);
20
+ return !value.startsWith("..") && !isAbsolute(value);
21
+ };
22
+ const inputError = () => new RehearsalError({
23
+ category: "configuration_invalid",
24
+ code: "CONFIGURATION_INVALID",
25
+ message: "Migration Insights requires bounded, unchanged regular SQL files inside the project; symlinks and unsafe filenames are refused.",
26
+ refused: "No SQL was executed and no database or engine was contacted.",
27
+ suggestions: [
28
+ "Review project-local migration files and the documented Insights input limits, then request a fresh inspection.",
29
+ ],
30
+ });
31
+ export const createMigrationInsightsInput = (projectRoot) => {
32
+ const contents = new Map();
33
+ let totalBytes = 0;
34
+ let totalFiles = 0;
35
+ const readInventory = async (directory) => {
36
+ let ancestry;
37
+ try {
38
+ const lexicalRoot = resolve(projectRoot);
39
+ const lexicalDirectory = resolve(fileURLToPath(directory));
40
+ if (!inside(lexicalRoot, lexicalDirectory))
41
+ throw inputError();
42
+ const root = await realpath(lexicalRoot);
43
+ const canonicalDirectory = await realpath(lexicalDirectory);
44
+ if (canonicalDirectory !==
45
+ resolve(root, relative(lexicalRoot, lexicalDirectory)))
46
+ throw inputError();
47
+ const held = await holdPathAncestry(canonicalDirectory);
48
+ ancestry = held;
49
+ await held.assertUnchanged();
50
+ const parentBefore = await lstat(canonicalDirectory);
51
+ if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink())
52
+ throw inputError();
53
+ const inventory = await readMigrationFileInventory(pathToFileURL(`${held.path}${sep}`), {
54
+ maximumFiles: MIGRATION_INSIGHTS_LIMITS.maximumFiles,
55
+ maximumDirectoryEntries: MIGRATION_INSIGHTS_LIMITS.maximumDirectoryEntries,
56
+ readBytes: async (url, filename) => {
57
+ if (++totalFiles > MIGRATION_INSIGHTS_LIMITS.maximumFiles ||
58
+ Buffer.byteLength(filename) > 255 ||
59
+ /[\\/%:#?\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(filename))
60
+ throw inputError();
61
+ const suppliedPath = fileURLToPath(url);
62
+ if (suppliedPath !== join(held.path, filename) ||
63
+ basename(suppliedPath) !== filename)
64
+ throw inputError();
65
+ // Read through the canonical directory, never a caller's root alias.
66
+ const path = join(canonicalDirectory, filename);
67
+ try {
68
+ await held.assertUnchanged();
69
+ const leafBefore = await lstat(path);
70
+ if (!leafBefore.isFile() || leafBefore.isSymbolicLink())
71
+ throw inputError();
72
+ const handle = await open(path, constants.O_RDONLY |
73
+ constants.O_NOFOLLOW |
74
+ constants.O_NONBLOCK);
75
+ try {
76
+ const before = await handle.stat();
77
+ if (!before.isFile() ||
78
+ before.dev !== leafBefore.dev ||
79
+ before.ino !== leafBefore.ino ||
80
+ before.size > MIGRATION_INSIGHTS_LIMITS.maximumFileBytes ||
81
+ totalBytes + before.size >
82
+ MIGRATION_INSIGHTS_LIMITS.maximumTotalBytes)
83
+ throw inputError();
84
+ const buffer = Buffer.alloc(before.size + 1);
85
+ let offset = 0;
86
+ while (offset < buffer.length) {
87
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
88
+ if (!bytesRead)
89
+ break;
90
+ offset += bytesRead;
91
+ }
92
+ const after = await handle.stat();
93
+ const leafAfter = await lstat(path);
94
+ const parentAfter = await lstat(canonicalDirectory);
95
+ if (offset !== before.size ||
96
+ before.dev !== after.dev ||
97
+ before.ino !== after.ino ||
98
+ before.size !== after.size ||
99
+ before.mtimeMs !== after.mtimeMs ||
100
+ before.ctimeMs !== after.ctimeMs ||
101
+ !leafAfter.isFile() ||
102
+ leafAfter.isSymbolicLink() ||
103
+ before.dev !== leafAfter.dev ||
104
+ before.ino !== leafAfter.ino ||
105
+ before.ctimeMs !== leafAfter.ctimeMs ||
106
+ parentAfter.isSymbolicLink() ||
107
+ parentBefore.dev !== parentAfter.dev ||
108
+ parentBefore.ino !== parentAfter.ino ||
109
+ (await realpath(dirname(path))) !== canonicalDirectory)
110
+ throw inputError();
111
+ await held.assertUnchanged();
112
+ const bytes = buffer.subarray(0, offset);
113
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
114
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
115
+ const prior = contents.get(path);
116
+ if (prior && prior.sha256 !== sha256)
117
+ throw inputError();
118
+ totalBytes += offset;
119
+ contents.set(path, { sha256, text });
120
+ return bytes;
121
+ }
122
+ finally {
123
+ await handle.close();
124
+ }
125
+ }
126
+ catch {
127
+ throw inputError();
128
+ }
129
+ },
130
+ });
131
+ await held.assertUnchanged();
132
+ return inventory;
133
+ }
134
+ catch {
135
+ throw inputError();
136
+ }
137
+ finally {
138
+ try {
139
+ await ancestry?.close();
140
+ }
141
+ catch {
142
+ throw inputError();
143
+ }
144
+ }
145
+ };
146
+ return {
147
+ readInventory,
148
+ sourceFor: (path, sha256) => {
149
+ const retained = contents.get(resolve(path));
150
+ return retained?.sha256 === sha256 ? retained.text : undefined;
151
+ },
152
+ };
153
+ };
@@ -0,0 +1,14 @@
1
+ /** Bounded PostgreSQL lexical input, not a SQL parser or validator. */
2
+ export type ScanUnknownReason = "unsupported_sql" | "malformed_sql" | "input_limit";
3
+ export interface SqlToken {
4
+ kind: "word" | "identifier" | "literal" | "symbol";
5
+ value: string;
6
+ line: number;
7
+ column: number;
8
+ depth: number;
9
+ }
10
+ export declare const lexMigrationSql: (source: string) => {
11
+ tokens: SqlToken[];
12
+ unknownReasons: ScanUnknownReason[];
13
+ ambiguousQuotes: boolean;
14
+ };
@@ -0,0 +1,204 @@
1
+ const maxBytes = 2 * 1024 * 1024;
2
+ const maxTokens = 20_000;
3
+ const maxDepth = 128;
4
+ const wordStart = (character) => /[A-Za-z_]/u.test(character) || character.charCodeAt(0) >= 128;
5
+ const wordPart = (character) => wordStart(character) || /[0-9$]/u.test(character);
6
+ export const lexMigrationSql = (source) => {
7
+ const tokens = [];
8
+ const reasons = new Set();
9
+ if (Buffer.byteLength(source, "utf8") > maxBytes)
10
+ return { tokens, unknownReasons: ["input_limit"], ambiguousQuotes: false };
11
+ if (source.includes("\0"))
12
+ reasons.add("malformed_sql");
13
+ let ambiguousQuotes = false;
14
+ let offset = 0;
15
+ let line = 1;
16
+ let column = 1;
17
+ const groups = [];
18
+ const advance = (end) => {
19
+ while (offset < end) {
20
+ const character = source[offset++];
21
+ if (character === "\r") {
22
+ if (source[offset] === "\n" && offset < end)
23
+ offset += 1;
24
+ line += 1;
25
+ column = 1;
26
+ }
27
+ else if (character === "\n") {
28
+ line += 1;
29
+ column = 1;
30
+ }
31
+ else
32
+ column += 1;
33
+ }
34
+ };
35
+ const emit = (kind, value, end, depth = groups.length) => {
36
+ if (tokens.length >= maxTokens) {
37
+ reasons.add("input_limit");
38
+ return false;
39
+ }
40
+ tokens.push({ kind, value, line, column, depth });
41
+ advance(end);
42
+ return true;
43
+ };
44
+ const quoted = (start, quote, escapes) => {
45
+ let cursor = start + 1;
46
+ while (cursor < source.length) {
47
+ if (source[cursor] === "\0")
48
+ reasons.add("malformed_sql");
49
+ if (source[cursor] === "\\") {
50
+ // Plain strings depend on standard_conforming_strings, which is not read.
51
+ if (quote === "'" && !escapes) {
52
+ reasons.add("unsupported_sql");
53
+ ambiguousQuotes = true;
54
+ }
55
+ if (escapes) {
56
+ cursor += 2;
57
+ continue;
58
+ }
59
+ }
60
+ if (source[cursor] === quote) {
61
+ if (source[cursor + 1] === quote)
62
+ cursor += 2;
63
+ else
64
+ return cursor + 1;
65
+ }
66
+ else
67
+ cursor += 1;
68
+ }
69
+ reasons.add("malformed_sql");
70
+ return source.length;
71
+ };
72
+ while (offset < source.length) {
73
+ const character = source[offset];
74
+ if (/[ \t\n\r\f\v]/u.test(character)) {
75
+ // Consume CRLF together so editor positions count a single newline.
76
+ advance(offset + (character === "\r" && source[offset + 1] === "\n" ? 2 : 1));
77
+ continue;
78
+ }
79
+ if (source.startsWith("--", offset)) {
80
+ let end = offset + 2;
81
+ while (end < source.length && !/[\r\n]/u.test(source[end]))
82
+ end += 1;
83
+ advance(end);
84
+ continue;
85
+ }
86
+ if (source.startsWith("/*", offset)) {
87
+ let nested = 1;
88
+ let cursor = offset + 2;
89
+ while (cursor < source.length && nested > 0) {
90
+ if (source.startsWith("/*", cursor)) {
91
+ nested += 1;
92
+ cursor += 2;
93
+ if (nested > maxDepth)
94
+ break;
95
+ }
96
+ else if (source.startsWith("*/", cursor)) {
97
+ nested -= 1;
98
+ cursor += 2;
99
+ }
100
+ else
101
+ cursor += 1;
102
+ }
103
+ if (nested > maxDepth) {
104
+ reasons.add("input_limit");
105
+ break;
106
+ }
107
+ if (nested > 0)
108
+ reasons.add("malformed_sql");
109
+ advance(cursor);
110
+ continue;
111
+ }
112
+ if (source.startsWith("*/", offset) || character === "\0") {
113
+ reasons.add("malformed_sql");
114
+ break;
115
+ }
116
+ if ((character === "E" || character === "e") &&
117
+ source[offset + 1] === "'") {
118
+ if (!emit("literal", "", quoted(offset + 1, "'", true)))
119
+ break;
120
+ continue;
121
+ }
122
+ if (character === "'" || character === '"') {
123
+ const end = quoted(offset, character, false);
124
+ if (character === '"' && end === offset + 2)
125
+ reasons.add("malformed_sql");
126
+ if (!emit(character === "'" ? "literal" : "identifier", "", end))
127
+ break;
128
+ continue;
129
+ }
130
+ if (character === "$") {
131
+ let tagEnd = offset + 1;
132
+ if (wordStart(source[tagEnd] ?? "")) {
133
+ tagEnd += 1;
134
+ while (tagEnd < source.length &&
135
+ (wordStart(source[tagEnd]) || /[0-9]/u.test(source[tagEnd])))
136
+ tagEnd += 1;
137
+ }
138
+ if (source[tagEnd] === "$") {
139
+ const delimiter = source.slice(offset, tagEnd + 1);
140
+ const close = source.indexOf(delimiter, tagEnd + 1);
141
+ if (close < 0)
142
+ reasons.add("malformed_sql");
143
+ if (!emit("literal", "", close < 0 ? source.length : close + delimiter.length))
144
+ break;
145
+ continue;
146
+ }
147
+ let parameterEnd = offset + 1;
148
+ while (/[0-9]/u.test(source[parameterEnd] ?? ""))
149
+ parameterEnd += 1;
150
+ if (parameterEnd > offset + 1) {
151
+ if (!emit("literal", "", parameterEnd))
152
+ break;
153
+ continue;
154
+ }
155
+ reasons.add("unsupported_sql");
156
+ }
157
+ if (wordStart(character)) {
158
+ let end = offset + 1;
159
+ while (end < source.length && wordPart(source[end]))
160
+ end += 1;
161
+ if (!emit("word", source.slice(offset, end).toUpperCase(), end))
162
+ break;
163
+ continue;
164
+ }
165
+ if (/[0-9]/u.test(character)) {
166
+ let end = offset + 1;
167
+ while (end < source.length && /[0-9A-Za-z_.]/u.test(source[end]))
168
+ end += 1;
169
+ if (!emit("literal", "", end))
170
+ break;
171
+ continue;
172
+ }
173
+ if (character === "(" || character === "[") {
174
+ if (groups.length >= maxDepth) {
175
+ reasons.add("input_limit");
176
+ break;
177
+ }
178
+ if (!emit("symbol", character, offset + 1))
179
+ break;
180
+ groups.push(character);
181
+ }
182
+ else if (character === ")" || character === "]") {
183
+ if (groups.pop() !== (character === ")" ? "(" : "[")) {
184
+ reasons.add("malformed_sql");
185
+ break;
186
+ }
187
+ if (!emit("symbol", character, offset + 1))
188
+ break;
189
+ }
190
+ else {
191
+ if (character === ";" && groups.length > 0)
192
+ reasons.add("malformed_sql");
193
+ if (character === "\\" ||
194
+ character === "`" ||
195
+ character.charCodeAt(0) < 32)
196
+ reasons.add("unsupported_sql");
197
+ if (!emit("symbol", character, offset + 1))
198
+ break;
199
+ }
200
+ }
201
+ if (groups.length > 0)
202
+ reasons.add("malformed_sql");
203
+ return { tokens, unknownReasons: [...reasons], ambiguousQuotes };
204
+ };
@@ -0,0 +1,16 @@
1
+ import { type ScanUnknownReason } from "./migration_sql_lexer.mjs";
2
+ export type MigrationSqlRule = "drop_table" | "drop_column" | "truncate" | "broad_update" | "broad_delete" | "not_null" | "new_constraint" | "cascade";
3
+ export interface MigrationSqlFinding {
4
+ rule: MigrationSqlRule;
5
+ severity: "warning" | "danger";
6
+ line: number;
7
+ column: number;
8
+ }
9
+ export interface MigrationSqlScan {
10
+ /** Complete bounded lexical/rule coverage, never SQL validity or a safe verdict. */
11
+ complete: boolean;
12
+ findings: MigrationSqlFinding[];
13
+ unknownReasons: ScanUnknownReason[];
14
+ }
15
+ /** Static, local-only review hints. Never executes SQL or returns input text. */
16
+ export declare const scanMigrationSql: (source: string) => MigrationSqlScan;