@penclipai/db 2026.704.0 → 2026.714.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 (122) hide show
  1. package/dist/backup-lib.test.js +1 -1
  2. package/dist/backup-lib.test.js.map +1 -1
  3. package/dist/check-migration-safety.d.ts +27 -0
  4. package/dist/check-migration-safety.d.ts.map +1 -0
  5. package/dist/check-migration-safety.js +811 -0
  6. package/dist/check-migration-safety.js.map +1 -0
  7. package/dist/check-migration-safety.test.d.ts +2 -0
  8. package/dist/check-migration-safety.test.d.ts.map +1 -0
  9. package/dist/check-migration-safety.test.js +391 -0
  10. package/dist/check-migration-safety.test.js.map +1 -0
  11. package/dist/client.test.js +743 -0
  12. package/dist/client.test.js.map +1 -1
  13. package/dist/issue-comment-derived-attribution-migration.test.d.ts +2 -0
  14. package/dist/issue-comment-derived-attribution-migration.test.d.ts.map +1 -0
  15. package/dist/issue-comment-derived-attribution-migration.test.js +287 -0
  16. package/dist/issue-comment-derived-attribution-migration.test.js.map +1 -0
  17. package/dist/migration-safety-baseline.d.ts +135 -0
  18. package/dist/migration-safety-baseline.d.ts.map +1 -0
  19. package/dist/migration-safety-baseline.js +150 -0
  20. package/dist/migration-safety-baseline.js.map +1 -0
  21. package/dist/migrations/0128_user_specific_secrets.sql +178 -0
  22. package/dist/migrations/0129_agent_api_key_responsible_user.sql +41 -0
  23. package/dist/migrations/0131_repair_run_responsible_user_context_refs.sql +67 -0
  24. package/dist/migrations/0132_issue_comment_derived_attribution_fast.sql +82 -0
  25. package/dist/migrations/0133_resource_membership_stars.sql +7 -0
  26. package/dist/migrations/0134_run_responsible_user_invariant.sql +211 -0
  27. package/dist/migrations/0135_repair_run_responsible_user_updated_at_sweep.sql +84 -0
  28. package/dist/migrations/0136_acpx_default_engine_migration.sql +97 -0
  29. package/dist/migrations/0137_skill_studio_server_foundation.sql +76 -0
  30. package/dist/migrations/0138_skill_studio_run_retention.sql +5 -0
  31. package/dist/migrations/0139_skill_studio_run_templates.sql +44 -0
  32. package/dist/migrations/0140_built_in_managed_resources.sql +43 -0
  33. package/dist/migrations/0141_heartbeat_runs_company_created_at_index.sql +2 -0
  34. package/dist/migrations/0142_company_search_sort_indexes.sql +3 -0
  35. package/dist/migrations/0143_cases_foundation.sql +248 -0
  36. package/dist/migrations/0144_case_document_annotations.sql +31 -0
  37. package/dist/migrations/0145_inbox_dismissal_snooze_kind.sql +15 -0
  38. package/dist/migrations/0146_routine_activity_gate.sql +3 -0
  39. package/dist/migrations/0147_cost_event_status.sql +1 -0
  40. package/dist/migrations/meta/_journal.json +132 -6
  41. package/dist/schema/agent_api_keys.d.ts +23 -0
  42. package/dist/schema/agent_api_keys.d.ts.map +1 -1
  43. package/dist/schema/agent_api_keys.js +1 -0
  44. package/dist/schema/agent_api_keys.js.map +1 -1
  45. package/dist/schema/agent_memberships.d.ts +17 -0
  46. package/dist/schema/agent_memberships.d.ts.map +1 -1
  47. package/dist/schema/agent_memberships.js +2 -0
  48. package/dist/schema/agent_memberships.js.map +1 -1
  49. package/dist/schema/built_in_managed_resources.d.ts +197 -0
  50. package/dist/schema/built_in_managed_resources.d.ts.map +1 -0
  51. package/dist/schema/built_in_managed_resources.js +22 -0
  52. package/dist/schema/built_in_managed_resources.js.map +1 -0
  53. package/dist/schema/cases.d.ts +982 -0
  54. package/dist/schema/cases.d.ts.map +1 -0
  55. package/dist/schema/cases.js +122 -0
  56. package/dist/schema/cases.js.map +1 -0
  57. package/dist/schema/companies.d.ts +17 -0
  58. package/dist/schema/companies.d.ts.map +1 -1
  59. package/dist/schema/companies.js +1 -0
  60. package/dist/schema/companies.js.map +1 -1
  61. package/dist/schema/company_secrets.d.ts +51 -0
  62. package/dist/schema/company_secrets.d.ts.map +1 -1
  63. package/dist/schema/company_secrets.js +27 -3
  64. package/dist/schema/company_secrets.js.map +1 -1
  65. package/dist/schema/company_skills.d.ts +788 -0
  66. package/dist/schema/company_skills.d.ts.map +1 -1
  67. package/dist/schema/company_skills.js +63 -0
  68. package/dist/schema/company_skills.js.map +1 -1
  69. package/dist/schema/cost_events.d.ts +17 -0
  70. package/dist/schema/cost_events.d.ts.map +1 -1
  71. package/dist/schema/cost_events.js +1 -0
  72. package/dist/schema/cost_events.js.map +1 -1
  73. package/dist/schema/document_annotation_comments.d.ts +17 -0
  74. package/dist/schema/document_annotation_comments.d.ts.map +1 -1
  75. package/dist/schema/document_annotation_comments.js +4 -1
  76. package/dist/schema/document_annotation_comments.js.map +1 -1
  77. package/dist/schema/document_annotation_threads.d.ts +17 -0
  78. package/dist/schema/document_annotation_threads.d.ts.map +1 -1
  79. package/dist/schema/document_annotation_threads.js +4 -1
  80. package/dist/schema/document_annotation_threads.js.map +1 -1
  81. package/dist/schema/heartbeat_runs.d.ts +17 -0
  82. package/dist/schema/heartbeat_runs.d.ts.map +1 -1
  83. package/dist/schema/heartbeat_runs.js +3 -0
  84. package/dist/schema/heartbeat_runs.js.map +1 -1
  85. package/dist/schema/inbox_dismissals.d.ts +36 -0
  86. package/dist/schema/inbox_dismissals.d.ts.map +1 -1
  87. package/dist/schema/inbox_dismissals.js +2 -0
  88. package/dist/schema/inbox_dismissals.js.map +1 -1
  89. package/dist/schema/index.d.ts +5 -1
  90. package/dist/schema/index.d.ts.map +1 -1
  91. package/dist/schema/index.js +5 -1
  92. package/dist/schema/index.js.map +1 -1
  93. package/dist/schema/issues.d.ts +34 -0
  94. package/dist/schema/issues.d.ts.map +1 -1
  95. package/dist/schema/issues.js +7 -0
  96. package/dist/schema/issues.js.map +1 -1
  97. package/dist/schema/project_memberships.d.ts +17 -0
  98. package/dist/schema/project_memberships.d.ts.map +1 -1
  99. package/dist/schema/project_memberships.js +2 -0
  100. package/dist/schema/project_memberships.js.map +1 -1
  101. package/dist/schema/routines.d.ts +85 -0
  102. package/dist/schema/routines.d.ts.map +1 -1
  103. package/dist/schema/routines.js +8 -0
  104. package/dist/schema/routines.js.map +1 -1
  105. package/dist/schema/secret_access_events.d.ts +103 -1
  106. package/dist/schema/secret_access_events.d.ts.map +1 -1
  107. package/dist/schema/secret_access_events.js +10 -1
  108. package/dist/schema/secret_access_events.js.map +1 -1
  109. package/dist/schema/user_secret_declarations.d.ts +229 -0
  110. package/dist/schema/user_secret_declarations.d.ts.map +1 -0
  111. package/dist/schema/user_secret_declarations.js +31 -0
  112. package/dist/schema/user_secret_declarations.js.map +1 -0
  113. package/dist/schema/user_secret_definitions.d.ts +316 -0
  114. package/dist/schema/user_secret_definitions.d.ts.map +1 -0
  115. package/dist/schema/user_secret_definitions.js +33 -0
  116. package/dist/schema/user_secret_definitions.js.map +1 -0
  117. package/dist/table-size-estimates.d.ts +372 -0
  118. package/dist/table-size-estimates.d.ts.map +1 -0
  119. package/dist/table-size-estimates.js +155 -0
  120. package/dist/table-size-estimates.js.map +1 -0
  121. package/package.json +3 -3
  122. package/dist/migrations/0126_issue_comment_derived_attribution.sql +0 -69
@@ -0,0 +1,811 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { MIGRATION_SAFETY_BASELINE } from "./migration-safety-baseline.js";
6
+ import { getTableSizeEstimate, isKnownLargeTable, } from "./table-size-estimates.js";
7
+ const migrationsDir = fileURLToPath(new URL("./migrations", import.meta.url));
8
+ const RULE_METADATA = {
9
+ "loop-mutation-large-table": {
10
+ severity: "error",
11
+ message: "DO $$ loop mutates a known-large table without a same-migration support index",
12
+ },
13
+ "batched-mutation-large-table-missing-index": {
14
+ severity: "error",
15
+ message: "Batched LIMIT mutation over a known-large table lacks a same-migration support index",
16
+ },
17
+ "full-table-mutation-large-table": {
18
+ severity: "error",
19
+ message: "Known-large table mutation does not have a selective WHERE clause",
20
+ },
21
+ "large-create-index-not-concurrently": {
22
+ severity: "warning",
23
+ message: "CREATE INDEX on a known-large table is missing CONCURRENTLY",
24
+ },
25
+ };
26
+ const RESERVED_ALIAS_WORDS = new Set([
27
+ "add",
28
+ "alter",
29
+ "as",
30
+ "delete",
31
+ "from",
32
+ "on",
33
+ "returning",
34
+ "set",
35
+ "using",
36
+ "where",
37
+ "with",
38
+ ]);
39
+ function normalizeIdentifier(value) {
40
+ return value
41
+ .trim()
42
+ .replace(/^"public"\s*\.\s*/i, "")
43
+ .replace(/^public\s*\.\s*/i, "")
44
+ .replace(/^"/, "")
45
+ .replace(/"$/, "")
46
+ .replaceAll('""', '"');
47
+ }
48
+ function normalizeSql(value) {
49
+ return value.replace(/\s+/g, " ").trim();
50
+ }
51
+ function statementExcerpt(statement) {
52
+ const normalized = normalizeSql(statement);
53
+ if (normalized.length <= 700)
54
+ return normalized;
55
+ return `${normalized.slice(0, 700)}...`;
56
+ }
57
+ function findingId(rule, migration, table, statement) {
58
+ return createHash("sha256")
59
+ .update(`${rule}\0${migration}\0${table}\0${normalizeSql(statement)}`)
60
+ .digest("hex")
61
+ .slice(0, 16);
62
+ }
63
+ function splitSqlStatements(sql) {
64
+ const breakpointParts = sql
65
+ .split(/-->\s*statement-breakpoint/g)
66
+ .map((part) => part.trim())
67
+ .filter((part) => part.length > 0);
68
+ if (breakpointParts.length > 1)
69
+ return breakpointParts;
70
+ const statements = [];
71
+ let start = 0;
72
+ let singleQuoted = false;
73
+ let dollarQuoteTag = null;
74
+ for (let index = 0; index < sql.length; index += 1) {
75
+ if (dollarQuoteTag) {
76
+ if (sql.startsWith(dollarQuoteTag, index)) {
77
+ index += dollarQuoteTag.length - 1;
78
+ dollarQuoteTag = null;
79
+ }
80
+ continue;
81
+ }
82
+ const char = sql[index];
83
+ if (singleQuoted) {
84
+ if (char === "'" && sql[index + 1] === "'") {
85
+ index += 1;
86
+ }
87
+ else if (char === "'") {
88
+ singleQuoted = false;
89
+ }
90
+ continue;
91
+ }
92
+ if (char === "'") {
93
+ singleQuoted = true;
94
+ continue;
95
+ }
96
+ if (char === "$") {
97
+ const tag = dollarQuoteTagAt(sql, index);
98
+ if (tag) {
99
+ dollarQuoteTag = tag;
100
+ index += dollarQuoteTag.length - 1;
101
+ }
102
+ continue;
103
+ }
104
+ if (char === ";") {
105
+ const statement = sql.slice(start, index + 1).trim();
106
+ if (statement.length > 0)
107
+ statements.push(statement);
108
+ start = index + 1;
109
+ }
110
+ }
111
+ const tail = sql.slice(start).trim();
112
+ if (tail.length > 0)
113
+ statements.push(tail);
114
+ return statements;
115
+ }
116
+ function ignoreRules(statement) {
117
+ const rules = new Set();
118
+ const pattern = /--\s*paperclip:migration-safety-ignore\s+([a-z0-9-]+|all)\s*:\s*(\S.*)$/gim;
119
+ for (const match of statement.matchAll(pattern)) {
120
+ const rule = match[1];
121
+ const reason = match[2]?.trim();
122
+ if (rule && reason)
123
+ rules.add(rule);
124
+ }
125
+ return rules;
126
+ }
127
+ function isIgnored(statement, rule) {
128
+ const ignored = ignoreRules(statement);
129
+ return ignored.has(rule) || ignored.has("all");
130
+ }
131
+ function skipSingleQuotedLiteral(statement, startIndex) {
132
+ let index = startIndex + 1;
133
+ while (index < statement.length) {
134
+ if (statement[index] === "'" && statement[index + 1] === "'") {
135
+ index += 2;
136
+ continue;
137
+ }
138
+ if (statement[index] === "'")
139
+ return index + 1;
140
+ index += 1;
141
+ }
142
+ return index;
143
+ }
144
+ function skipDoubleQuotedIdentifier(statement, startIndex) {
145
+ let index = startIndex + 1;
146
+ while (index < statement.length) {
147
+ if (statement[index] === '"' && statement[index + 1] === '"') {
148
+ index += 2;
149
+ continue;
150
+ }
151
+ if (statement[index] === '"')
152
+ return index + 1;
153
+ index += 1;
154
+ }
155
+ return index;
156
+ }
157
+ function skipLineComment(statement, startIndex) {
158
+ const newlineIndex = statement.indexOf("\n", startIndex + 2);
159
+ return newlineIndex === -1 ? statement.length : newlineIndex;
160
+ }
161
+ function skipBlockComment(statement, startIndex) {
162
+ let depth = 1;
163
+ let index = startIndex + 2;
164
+ while (index < statement.length && depth > 0) {
165
+ if (statement.startsWith("/*", index)) {
166
+ depth += 1;
167
+ index += 2;
168
+ continue;
169
+ }
170
+ if (statement.startsWith("*/", index)) {
171
+ depth -= 1;
172
+ index += 2;
173
+ continue;
174
+ }
175
+ index += 1;
176
+ }
177
+ return index;
178
+ }
179
+ function skipDollarQuotedString(statement, startIndex) {
180
+ const tag = dollarQuoteTagAt(statement, startIndex);
181
+ if (!tag)
182
+ return startIndex;
183
+ const closeIndex = statement.indexOf(tag, startIndex + tag.length);
184
+ return closeIndex === -1 ? statement.length : closeIndex + tag.length;
185
+ }
186
+ function stripSqlComments(statement) {
187
+ let stripped = "";
188
+ let index = 0;
189
+ while (index < statement.length) {
190
+ const char = statement[index];
191
+ const next = statement[index + 1];
192
+ if (char === "'") {
193
+ const literalEnd = skipSingleQuotedLiteral(statement, index);
194
+ stripped += statement.slice(index, literalEnd);
195
+ index = literalEnd;
196
+ continue;
197
+ }
198
+ if (char === '"') {
199
+ const identifierEnd = skipDoubleQuotedIdentifier(statement, index);
200
+ stripped += statement.slice(index, identifierEnd);
201
+ index = identifierEnd;
202
+ continue;
203
+ }
204
+ if (char === "$") {
205
+ const dollarEnd = skipDollarQuotedString(statement, index);
206
+ if (dollarEnd !== index) {
207
+ stripped += statement.slice(index, dollarEnd);
208
+ index = dollarEnd;
209
+ continue;
210
+ }
211
+ }
212
+ if (char === "-" && next === "-") {
213
+ stripped += " ";
214
+ index = skipLineComment(statement, index);
215
+ continue;
216
+ }
217
+ if (char === "/" && next === "*") {
218
+ stripped += " ";
219
+ const commentEnd = skipBlockComment(statement, index);
220
+ for (let commentIndex = index; commentIndex < commentEnd; commentIndex += 1) {
221
+ if (statement[commentIndex] === "\n")
222
+ stripped += "\n";
223
+ }
224
+ index = commentEnd;
225
+ continue;
226
+ }
227
+ stripped += char;
228
+ index += 1;
229
+ }
230
+ return stripped;
231
+ }
232
+ function skipSqlTrivia(statement, index) {
233
+ const char = statement[index];
234
+ const next = statement[index + 1];
235
+ if (char === "'")
236
+ return skipSingleQuotedLiteral(statement, index);
237
+ if (char === '"')
238
+ return skipDoubleQuotedIdentifier(statement, index);
239
+ if (char === "$") {
240
+ const end = skipDollarQuotedString(statement, index);
241
+ if (end !== index)
242
+ return end;
243
+ }
244
+ if (char === "-" && next === "-")
245
+ return skipLineComment(statement, index);
246
+ if (char === "/" && next === "*")
247
+ return skipBlockComment(statement, index);
248
+ return index;
249
+ }
250
+ function identifierList(value) {
251
+ return [...value.matchAll(/"([^"]+)"|(?:^|[\s,(])([A-Za-z_][A-Za-z0-9_]*)/g)]
252
+ .map((match) => normalizeIdentifier(match[1] ?? match[2] ?? ""))
253
+ .filter((identifier) => identifier.length > 0)
254
+ .filter((identifier) => !RESERVED_ALIAS_WORDS.has(identifier.toLowerCase()));
255
+ }
256
+ function plainIndexColumns(columnSpec) {
257
+ // Only include simple column references. Skip expression columns such as
258
+ // (id * 2) or lower(col) — PostgreSQL cannot use those to satisfy ORDER BY
259
+ // on the plain column, so they must not suppress a missing-index finding.
260
+ return splitSqlList(columnSpec).flatMap((part) => {
261
+ const trimmed = part.trim();
262
+ if (/\(/.test(trimmed))
263
+ return [];
264
+ return identifierList(trimmed);
265
+ });
266
+ }
267
+ function splitSqlList(value) {
268
+ const parts = [];
269
+ let start = 0;
270
+ let depth = 0;
271
+ let singleQuoted = false;
272
+ let doubleQuoted = false;
273
+ for (let index = 0; index < value.length; index += 1) {
274
+ const char = value[index];
275
+ if (singleQuoted) {
276
+ if (char === "'" && value[index + 1] === "'") {
277
+ index += 1;
278
+ }
279
+ else if (char === "'") {
280
+ singleQuoted = false;
281
+ }
282
+ continue;
283
+ }
284
+ if (doubleQuoted) {
285
+ if (char === '"' && value[index + 1] === '"') {
286
+ index += 1;
287
+ }
288
+ else if (char === '"') {
289
+ doubleQuoted = false;
290
+ }
291
+ continue;
292
+ }
293
+ if (char === "'") {
294
+ singleQuoted = true;
295
+ continue;
296
+ }
297
+ if (char === '"') {
298
+ doubleQuoted = true;
299
+ continue;
300
+ }
301
+ if (char === "(") {
302
+ depth += 1;
303
+ continue;
304
+ }
305
+ if (char === ")") {
306
+ depth = Math.max(0, depth - 1);
307
+ continue;
308
+ }
309
+ if (char === "," && depth === 0) {
310
+ const part = value.slice(start, index).trim();
311
+ if (part)
312
+ parts.push(part);
313
+ start = index + 1;
314
+ }
315
+ }
316
+ const tail = value.slice(start).trim();
317
+ if (tail)
318
+ parts.push(tail);
319
+ return parts;
320
+ }
321
+ function orderByExpressionColumn(value) {
322
+ const withoutSortModifiers = value
323
+ .replace(/\bCOLLATE\s+(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)/gi, " ")
324
+ .replace(/\bNULLS\s+(?:FIRST|LAST)\b/gi, " ")
325
+ .replace(/\b(?:ASC|DESC)\b/gi, " ");
326
+ const identifiers = [
327
+ ...withoutSortModifiers.matchAll(/"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*)/g),
328
+ ]
329
+ .map((match) => normalizeIdentifier(match[1] ?? match[2] ?? ""))
330
+ .filter((identifier) => identifier.length > 0)
331
+ .filter((identifier) => !RESERVED_ALIAS_WORDS.has(identifier.toLowerCase()));
332
+ return identifiers[identifiers.length - 1] ?? null;
333
+ }
334
+ function predicateColumns(statement) {
335
+ const columns = new Set();
336
+ const sql = stripSqlComments(statement);
337
+ const predicatePattern = /\b(?:WHERE|ORDER\s+BY|ON)\b([\s\S]*?)(?=\b(?:LIMIT|RETURNING|GROUP\s+BY|ORDER\s+BY|SET|FROM)\b|$)/gi;
338
+ for (const match of sql.matchAll(predicatePattern)) {
339
+ for (const identifier of identifierList(match[1] ?? "")) {
340
+ columns.add(identifier);
341
+ }
342
+ }
343
+ return [...columns];
344
+ }
345
+ function keywordOccurrenceAt(sql, index, pattern) {
346
+ const previous = sql[index - 1];
347
+ if (previous && /[A-Za-z0-9_]/.test(previous))
348
+ return null;
349
+ return sql.slice(index).match(pattern);
350
+ }
351
+ function keywordOccurrences(sql, pattern) {
352
+ const occurrences = [];
353
+ let depth = 0;
354
+ let index = 0;
355
+ while (index < sql.length) {
356
+ const char = sql[index];
357
+ if (char === "'") {
358
+ index = skipSingleQuotedLiteral(sql, index);
359
+ continue;
360
+ }
361
+ if (char === '"') {
362
+ index = skipDoubleQuotedIdentifier(sql, index);
363
+ continue;
364
+ }
365
+ if (char === "(") {
366
+ depth += 1;
367
+ index += 1;
368
+ continue;
369
+ }
370
+ if (char === ")") {
371
+ depth = Math.max(0, depth - 1);
372
+ index += 1;
373
+ continue;
374
+ }
375
+ const match = keywordOccurrenceAt(sql, index, pattern);
376
+ if (match) {
377
+ occurrences.push({ index, depth, length: match[0].length });
378
+ index += match[0].length;
379
+ continue;
380
+ }
381
+ index += 1;
382
+ }
383
+ return occurrences;
384
+ }
385
+ function batchWhereClausesBeforeOrderBy(statement) {
386
+ const sql = stripSqlComments(statement);
387
+ const whereOccurrences = keywordOccurrences(sql, /^\bWHERE\b/i);
388
+ const orderByOccurrences = keywordOccurrences(sql, /^\bORDER\s+BY\b/i);
389
+ const clauses = [];
390
+ for (const orderBy of orderByOccurrences) {
391
+ const where = whereOccurrences
392
+ .filter((candidate) => candidate.depth === orderBy.depth && candidate.index < orderBy.index)
393
+ .at(-1);
394
+ if (!where)
395
+ continue;
396
+ const clause = sql.slice(where.index + where.length, orderBy.index).trim();
397
+ if (clause)
398
+ clauses.push(clause);
399
+ }
400
+ return clauses;
401
+ }
402
+ function hasBalancedOuterParens(value) {
403
+ if (!value.startsWith("(") || !value.endsWith(")"))
404
+ return false;
405
+ let depth = 0;
406
+ for (let index = 0; index < value.length; index += 1) {
407
+ const char = value[index];
408
+ if (char === "'") {
409
+ index = skipSingleQuotedLiteral(value, index) - 1;
410
+ continue;
411
+ }
412
+ if (char === '"') {
413
+ index = skipDoubleQuotedIdentifier(value, index) - 1;
414
+ continue;
415
+ }
416
+ if (char === "(")
417
+ depth += 1;
418
+ if (char === ")")
419
+ depth -= 1;
420
+ if (depth === 0 && index < value.length - 1)
421
+ return false;
422
+ }
423
+ return depth === 0;
424
+ }
425
+ function stripOuterParens(value) {
426
+ let stripped = normalizeSql(value).replace(/;$/, "").trim();
427
+ while (hasBalancedOuterParens(stripped)) {
428
+ stripped = normalizeSql(stripped.slice(1, -1));
429
+ }
430
+ return stripped;
431
+ }
432
+ function splitConjunctivePredicate(value) {
433
+ const terms = [];
434
+ const sql = stripOuterParens(value);
435
+ let depth = 0;
436
+ let start = 0;
437
+ let index = 0;
438
+ while (index < sql.length) {
439
+ const char = sql[index];
440
+ if (char === "'") {
441
+ index = skipSingleQuotedLiteral(sql, index);
442
+ continue;
443
+ }
444
+ if (char === '"') {
445
+ index = skipDoubleQuotedIdentifier(sql, index);
446
+ continue;
447
+ }
448
+ if (char === "(") {
449
+ depth += 1;
450
+ index += 1;
451
+ continue;
452
+ }
453
+ if (char === ")") {
454
+ depth = Math.max(0, depth - 1);
455
+ index += 1;
456
+ continue;
457
+ }
458
+ const match = depth === 0 ? keywordOccurrenceAt(sql, index, /^\bAND\b/i) : null;
459
+ if (match) {
460
+ const term = stripOuterParens(sql.slice(start, index));
461
+ if (term)
462
+ terms.push(term);
463
+ start = index + match[0].length;
464
+ index = start;
465
+ continue;
466
+ }
467
+ index += 1;
468
+ }
469
+ const tail = stripOuterParens(sql.slice(start));
470
+ if (tail)
471
+ terms.push(tail);
472
+ return terms;
473
+ }
474
+ function lowercaseSqlOutsideSingleQuotedLiterals(value) {
475
+ let lowered = "";
476
+ let index = 0;
477
+ while (index < value.length) {
478
+ if (value[index] === "'") {
479
+ const end = skipSingleQuotedLiteral(value, index);
480
+ lowered += value.slice(index, end);
481
+ index = end;
482
+ continue;
483
+ }
484
+ lowered += value[index]?.toLowerCase() ?? "";
485
+ index += 1;
486
+ }
487
+ return lowered;
488
+ }
489
+ function normalizePredicateTerm(term) {
490
+ const normalized = stripOuterParens(term)
491
+ .replace(/(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*\.\s*("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)/g, "$1")
492
+ .replace(/"([^"]+)"/g, "$1")
493
+ .replace(/\s+/g, " ")
494
+ .trim();
495
+ return lowercaseSqlOutsideSingleQuotedLiterals(normalized);
496
+ }
497
+ function predicateTerms(value) {
498
+ return new Set(splitConjunctivePredicate(value).map(normalizePredicateTerm));
499
+ }
500
+ function orderByColumns(statement) {
501
+ const columns = [];
502
+ const sql = stripSqlComments(statement);
503
+ const pattern = /\bORDER\s+BY\b([\s\S]*?)(?=\b(?:LIMIT|RETURNING|GROUP\s+BY|WHERE|SET|FROM|END|LOOP)\b|$)/gi;
504
+ for (const match of sql.matchAll(pattern)) {
505
+ for (const expression of splitSqlList(match[1] ?? "")) {
506
+ const column = orderByExpressionColumn(expression);
507
+ if (column && !columns.includes(column)) {
508
+ columns.push(column);
509
+ }
510
+ }
511
+ }
512
+ return columns;
513
+ }
514
+ function parseCreateIndexes(statement) {
515
+ const indexes = [];
516
+ const sql = stripSqlComments(statement);
517
+ const pattern = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+ON\s+(?:(?:"public"|public)\s*\.\s*)?(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))\s*(?:USING\s+[A-Za-z_][A-Za-z0-9_]*\s*)?\(([\s\S]*?)\)(?:\s+WHERE\s+([\s\S]*))?/gi;
518
+ for (const match of sql.matchAll(pattern)) {
519
+ const table = normalizeIdentifier(match[2] ?? match[3] ?? "");
520
+ if (!table)
521
+ continue;
522
+ const predicate = match[5]?.trim().replace(/;$/, "").trim() ?? "";
523
+ indexes.push({
524
+ table,
525
+ columns: plainIndexColumns(match[4] ?? ""),
526
+ predicate: predicate.length > 0 ? predicate : null,
527
+ predicateColumns: predicateColumns(predicate),
528
+ concurrently: Boolean(match[1]),
529
+ statement,
530
+ });
531
+ }
532
+ return indexes;
533
+ }
534
+ function parseMutations(statement) {
535
+ const mutations = [];
536
+ const sql = stripSqlComments(statement);
537
+ const updatePattern = /\bUPDATE\s+(?:ONLY\s+)?(?:(?:"public"|public)\s*\.\s*)?(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))(?:\s+(?:AS\s+)?(?:"?([A-Za-z_][A-Za-z0-9_]*)"?))?/gi;
538
+ const deletePattern = /\bDELETE\s+FROM\s+(?:ONLY\s+)?(?:(?:"public"|public)\s*\.\s*)?(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))(?:\s+(?:AS\s+)?(?:"?([A-Za-z_][A-Za-z0-9_]*)"?))?/gi;
539
+ for (const match of sql.matchAll(updatePattern)) {
540
+ const table = normalizeIdentifier(match[1] ?? match[2] ?? "");
541
+ if (table) {
542
+ mutations.push({ table, statementSql: sql, keywordIndex: match.index ?? 0 });
543
+ }
544
+ }
545
+ for (const match of sql.matchAll(deletePattern)) {
546
+ const table = normalizeIdentifier(match[1] ?? match[2] ?? "");
547
+ if (table) {
548
+ mutations.push({ table, statementSql: sql, keywordIndex: match.index ?? 0 });
549
+ }
550
+ }
551
+ return mutations;
552
+ }
553
+ function hasDoLoop(statement) {
554
+ return /\bDO\s+\$[A-Za-z_]*\$[\s\S]*\bLOOP\b/i.test(statement);
555
+ }
556
+ function hasBatchedLimitMutation(statement) {
557
+ const hasLimit = /\bLIMIT\s+(?:\d+|[A-Za-z_][A-Za-z0-9_]*|\$[0-9]+)\b/i.test(statement);
558
+ const hasFetchLimit = /\bFETCH\s+(?:FIRST|NEXT)(?:\s+(?:\d+|[A-Za-z_][A-Za-z0-9_]*|\$[0-9]+))?\s+ROWS?\s+(?:ONLY|WITH\s+TIES)\b/i.test(statement);
559
+ const hasDml = /\b(?:UPDATE|DELETE)\b/i.test(statement);
560
+ return (hasLimit || hasFetchLimit) && hasDml;
561
+ }
562
+ function dollarQuoteTagAt(statement, startIndex) {
563
+ if (statement[startIndex] !== "$")
564
+ return null;
565
+ const match = statement.slice(startIndex).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/);
566
+ return match?.[0] ?? null;
567
+ }
568
+ function topLevelWhereClause(statement, startIndex) {
569
+ // Walk character-by-character, tracking paren depth.
570
+ // Only consider WHERE keywords at depth 0 after the target mutation.
571
+ let depth = 0;
572
+ let i = startIndex;
573
+ while (i < statement.length) {
574
+ const nextIndex = skipSqlTrivia(statement, i);
575
+ if (nextIndex !== i) {
576
+ i = nextIndex;
577
+ continue;
578
+ }
579
+ const ch = statement[i];
580
+ if (ch === "(") {
581
+ depth++;
582
+ i++;
583
+ continue;
584
+ }
585
+ if (ch === ")") {
586
+ depth = Math.max(0, depth - 1);
587
+ i++;
588
+ continue;
589
+ }
590
+ if (depth === 0) {
591
+ const rem = statement.slice(i);
592
+ const m = rem.match(/^\bWHERE\b/i);
593
+ if (m)
594
+ return rem.slice(m[0].length);
595
+ }
596
+ i++;
597
+ }
598
+ return null;
599
+ }
600
+ function hasSelectiveWhere(mutation) {
601
+ const afterWhere = topLevelWhereClause(mutation.statementSql, mutation.keywordIndex);
602
+ if (!afterWhere)
603
+ return false;
604
+ const whereClause = normalizeSql(afterWhere).replace(/;$/, "");
605
+ if (/^(?:true|1\s*=\s*1)$/i.test(whereClause))
606
+ return false;
607
+ if (!/(?:=|<>|!=|<|>|\bIN\s*\(|\bEXISTS\s*\(|\bLIKE\b|\bIS\s+(?:NOT\s+)?NULL\b)/i.test(whereClause))
608
+ return false;
609
+ // A WHERE that only constrains joined tables is not a filter on the target table.
610
+ // Collect every table-qualified column reference (both "tbl"."col" and alias."col").
611
+ const qualRefs = [
612
+ ...whereClause.matchAll(/"([^"]+)"\s*\.\s*(?:"[^"]+"|\w+)/g),
613
+ ...whereClause.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\."[^"]+"/g),
614
+ ...whereClause.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\b/g),
615
+ ].map((m) => normalizeIdentifier(m[1] ?? "")).filter(Boolean);
616
+ if (qualRefs.length > 0) {
617
+ // Resolve unquoted aliases from UPDATE/FROM/JOIN clauses to their real table names.
618
+ const aliasMap = new Map();
619
+ const aliasPattern = /\b(?:UPDATE|FROM|JOIN)\s+(?:"public"\s*\.\s*)?(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))\s+(?:AS\s+)?(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))\b/gi;
620
+ for (const m of stripSqlComments(mutation.statementSql).matchAll(aliasPattern)) {
621
+ const tbl = normalizeIdentifier(m[1] ?? m[2] ?? "");
622
+ const alias = normalizeIdentifier(m[3] ?? m[4] ?? "");
623
+ if (tbl && alias && !RESERVED_ALIAS_WORDS.has(alias.toLowerCase()))
624
+ aliasMap.set(alias, tbl);
625
+ }
626
+ const refTables = qualRefs.map((r) => aliasMap.get(r) ?? r);
627
+ if (!refTables.some((t) => t === mutation.table)) {
628
+ // All refs resolve to other tables. Confirm no bare unqualified identifiers remain.
629
+ const noQual = whereClause
630
+ .replace(/"[^"]+"\s*\.\s*(?:"[^"]+"|\w+)/g, " ")
631
+ .replace(/\b[A-Za-z_][A-Za-z0-9_]*\s*\."[^"]+"/g, " ")
632
+ .replace(/\b[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*/g, " ")
633
+ .replace(/'(?:[^']|'')*'/g, " ");
634
+ const SQL_KW = /\b(?:AND|OR|NOT|IN|EXISTS|LIKE|IS|NULL|TRUE|FALSE|BETWEEN|CASE|WHEN|THEN|END|CAST|AS|ANY|ALL|SOME)\b/gi;
635
+ if (!/\b[A-Za-z_][A-Za-z0-9_]*\b/.test(noQual.replace(SQL_KW, " ")))
636
+ return false;
637
+ }
638
+ }
639
+ return true;
640
+ }
641
+ function hasLeadingOrderPrefix(supportIndex, orderColumns, statement) {
642
+ const indexedColumns = supportIndex.columns;
643
+ const prefixLength = Math.min(indexedColumns.length, orderColumns.length);
644
+ if (prefixLength === 0)
645
+ return false;
646
+ for (let index = 0; index < prefixLength; index += 1) {
647
+ if (indexedColumns[index] !== orderColumns[index])
648
+ return false;
649
+ }
650
+ if (!supportIndex.predicate)
651
+ return true;
652
+ const indexPredicateTerms = predicateTerms(supportIndex.predicate);
653
+ if (indexPredicateTerms.size === 0)
654
+ return false;
655
+ return batchWhereClausesBeforeOrderBy(statement).some((whereClause) => {
656
+ const batchTerms = predicateTerms(whereClause);
657
+ return [...indexPredicateTerms].every((term) => batchTerms.has(term));
658
+ });
659
+ }
660
+ function hasOrderPrefixCompatibleIndex(supportIndex, orderColumns, statement) {
661
+ return hasLeadingOrderPrefix(supportIndex, orderColumns, statement);
662
+ }
663
+ function hasMatchingSupportIndex(indexes, mutation, statement) {
664
+ const matchingIndexes = indexes.filter((index) => index.table === mutation.table);
665
+ if (matchingIndexes.length === 0)
666
+ return false;
667
+ // ORDER BY columns are the batch-progression key. If the statement orders its
668
+ // batch, the support index must cover the ordered key prefix using index key
669
+ // columns; predicate-only overlap cannot stand in for an unindexed cursor.
670
+ const orderCols = orderByColumns(statement);
671
+ const allPredicateCols = new Set(predicateColumns(statement));
672
+ if (allPredicateCols.size === 0)
673
+ return true;
674
+ if (orderCols.length > 0) {
675
+ return matchingIndexes.some((index) => hasOrderPrefixCompatibleIndex(index, orderCols, statement));
676
+ }
677
+ return matchingIndexes.some((index) => {
678
+ const indexedColumns = new Set([...index.columns, ...index.predicateColumns]);
679
+ return [...allPredicateCols].some((col) => indexedColumns.has(col));
680
+ });
681
+ }
682
+ function estimateSuffix(table, estimates) {
683
+ const estimate = estimates.get(table) ?? getTableSizeEstimate(table);
684
+ if (!estimate)
685
+ return "bucket=large";
686
+ return `bucket=${estimate.bucket}, localRows=${estimate.localRows}, estimatedRows=${estimate.estimatedRows}`;
687
+ }
688
+ function makeFinding(rule, migration, table, statement, estimates) {
689
+ const metadata = RULE_METADATA[rule];
690
+ return {
691
+ id: findingId(rule, migration, table, statement),
692
+ rule,
693
+ severity: metadata.severity,
694
+ migration,
695
+ table,
696
+ statement: statementExcerpt(statement),
697
+ message: `${metadata.message} (${estimateSuffix(table, estimates)})`,
698
+ };
699
+ }
700
+ function addFindingOnce(findings, seen, finding) {
701
+ const key = `${finding.rule}:${finding.migration}:${finding.table}:${finding.id}`;
702
+ if (seen.has(key))
703
+ return;
704
+ seen.add(key);
705
+ findings.push(finding);
706
+ }
707
+ function estimatesByTable(estimates) {
708
+ if (!estimates)
709
+ return new Map();
710
+ return new Map(estimates.map((estimate) => [estimate.table, estimate]));
711
+ }
712
+ function tableIsLarge(table, estimates) {
713
+ if (estimates.size > 0)
714
+ return estimates.get(table)?.bucket === "large";
715
+ return isKnownLargeTable(table);
716
+ }
717
+ export function analyzeMigrationSafety(migrations, options = {}) {
718
+ const findings = [];
719
+ const seen = new Set();
720
+ const estimates = estimatesByTable(options.estimates);
721
+ for (const migration of migrations) {
722
+ const statements = splitSqlStatements(migration.sql);
723
+ const migrationIndexes = statements.flatMap(parseCreateIndexes);
724
+ for (const statement of statements) {
725
+ for (const index of parseCreateIndexes(statement)) {
726
+ if (tableIsLarge(index.table, estimates) &&
727
+ !index.concurrently &&
728
+ !isIgnored(statement, "large-create-index-not-concurrently")) {
729
+ addFindingOnce(findings, seen, makeFinding("large-create-index-not-concurrently", migration.fileName, index.table, statement, estimates));
730
+ }
731
+ }
732
+ const mutations = parseMutations(statement)
733
+ .filter((mutation) => tableIsLarge(mutation.table, estimates));
734
+ for (const mutation of mutations) {
735
+ const hasSupportIndex = hasMatchingSupportIndex(migrationIndexes, mutation, statement);
736
+ if (hasDoLoop(statement) &&
737
+ !hasSupportIndex &&
738
+ !isIgnored(statement, "loop-mutation-large-table")) {
739
+ addFindingOnce(findings, seen, makeFinding("loop-mutation-large-table", migration.fileName, mutation.table, statement, estimates));
740
+ }
741
+ if (hasBatchedLimitMutation(statement) &&
742
+ !hasSupportIndex &&
743
+ !isIgnored(statement, "batched-mutation-large-table-missing-index")) {
744
+ addFindingOnce(findings, seen, makeFinding("batched-mutation-large-table-missing-index", migration.fileName, mutation.table, statement, estimates));
745
+ }
746
+ if (!hasSelectiveWhere(mutation) &&
747
+ !isIgnored(statement, "full-table-mutation-large-table")) {
748
+ addFindingOnce(findings, seen, makeFinding("full-table-mutation-large-table", migration.fileName, mutation.table, statement, estimates));
749
+ }
750
+ }
751
+ }
752
+ }
753
+ const baselineIds = new Set(options.baselineIds ?? MIGRATION_SAFETY_BASELINE.map((entry) => entry.id));
754
+ const foundIds = new Set(findings.map((finding) => finding.id));
755
+ const newFindings = findings.filter((finding) => !baselineIds.has(finding.id));
756
+ const baselineFindings = findings.filter((finding) => baselineIds.has(finding.id));
757
+ const staleBaselineIds = [...baselineIds].filter((id) => !foundIds.has(id));
758
+ return {
759
+ findings,
760
+ newFindings,
761
+ baselineFindings,
762
+ staleBaselineIds,
763
+ };
764
+ }
765
+ async function readMigrations() {
766
+ const files = (await readdir(migrationsDir))
767
+ .filter((entry) => entry.endsWith(".sql"))
768
+ .sort();
769
+ return Promise.all(files.map(async (fileName) => ({
770
+ fileName,
771
+ sql: await readFile(new URL(`./migrations/${fileName}`, import.meta.url), "utf8"),
772
+ })));
773
+ }
774
+ function formatFinding(finding) {
775
+ return [
776
+ `[${finding.rule}] ${finding.migration} table=${finding.table} severity=${finding.severity} id=${finding.id}`,
777
+ finding.message,
778
+ `Statement: ${finding.statement}`,
779
+ ].join("\n");
780
+ }
781
+ function formatNewFindings(findings) {
782
+ const rendered = findings.map(formatFinding).join("\n\n");
783
+ return [
784
+ `Migration safety check found ${findings.length} new finding(s).`,
785
+ "Add a same-migration support index, use CONCURRENTLY where applicable, or add",
786
+ "`-- paperclip:migration-safety-ignore <rule>: <reason>` next to the statement.",
787
+ "",
788
+ rendered,
789
+ ].join("\n");
790
+ }
791
+ async function main() {
792
+ const result = analyzeMigrationSafety(await readMigrations());
793
+ if (result.newFindings.length > 0) {
794
+ throw new Error(formatNewFindings(result.newFindings));
795
+ }
796
+ const staleSuffix = result.staleBaselineIds.length > 0
797
+ ? ` (${result.staleBaselineIds.length} stale baseline id(s) ignored)`
798
+ : "";
799
+ console.log(`Migration safety check passed: ${result.baselineFindings.length} historical finding(s) covered by baseline${staleSuffix}.`);
800
+ }
801
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
802
+ try {
803
+ await main();
804
+ }
805
+ catch (error) {
806
+ const detail = error instanceof Error ? error.message : String(error);
807
+ console.error(`${basename(process.argv[1])}: ${detail}`);
808
+ process.exitCode = 1;
809
+ }
810
+ }
811
+ //# sourceMappingURL=check-migration-safety.js.map