@everystack/cli 0.3.21 → 0.3.23
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/README.md +3 -1
- package/package.json +9 -4
- package/src/cli/apply-authority.ts +150 -0
- package/src/cli/commands/db-apply.ts +183 -5
- package/src/cli/commands/db-approvers.ts +71 -0
- package/src/cli/commands/db-check.ts +306 -0
- package/src/cli/commands/db-plan.ts +12 -0
- package/src/cli/commands/db-sync.ts +6 -1
- package/src/cli/edge-plan.ts +76 -34
- package/src/cli/git-descent.ts +251 -0
- package/src/cli/index.ts +11 -1
- package/src/cli/schema-source.ts +5 -0
- package/src/cli/state-apply.ts +30 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-descent — a protected database is a git ref (brick 7 of the
|
|
3
|
+
* migration-authority design).
|
|
4
|
+
*
|
|
5
|
+
* The rule git already taught everyone: `db:apply` is a fast-forward push.
|
|
6
|
+
* The target's live fingerprint maps to the git commit whose committed
|
|
7
|
+
* models DECLARE that state; the deploying checkout must be a descendant
|
|
8
|
+
* of such a commit, or the apply refuses — "rebase first". This is what
|
|
9
|
+
* stops the multi-dev disaster case: A merges and deploys, B (branched
|
|
10
|
+
* before A merged) mints against the target — the concurrency lock passes,
|
|
11
|
+
* because B's plan was minted against current reality, but B's edge would
|
|
12
|
+
* REVERT A's merged work. The descent check catches exactly that: the
|
|
13
|
+
* commit declaring the target's state is not in B's history.
|
|
14
|
+
*
|
|
15
|
+
* The search runs over GIT ALONE, never `schema_log` (the ledger is a
|
|
16
|
+
* memoir, not a control surface — design decision 7): enumerate every
|
|
17
|
+
* historical state of the models directory across all refs, compile each
|
|
18
|
+
* unique tree, and ask whether its predicted live endpoint equals the
|
|
19
|
+
* target's live fingerprint (brick 4's merged prediction, so unmodeled
|
|
20
|
+
* brownfield tables ride through the test). A fingerprint no commit
|
|
21
|
+
* declares is a DRIFT report and an explicit operator decision — never a
|
|
22
|
+
* guess. Identity is the models-directory TREE, not the commit: a
|
|
23
|
+
* cherry-picked identical state is the same declared state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
32
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
33
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
34
|
+
import { fingerprintLive } from './schema-fingerprint.js';
|
|
35
|
+
import { predictLiveFingerprint } from './edge-plan.js';
|
|
36
|
+
|
|
37
|
+
/** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
|
|
38
|
+
export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
|
|
39
|
+
|
|
40
|
+
export interface LiveState {
|
|
41
|
+
snapshot: SchemaSnapshot;
|
|
42
|
+
contract: AuthzContract;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DescentOptions {
|
|
46
|
+
/** Where git commands run. Default: process.cwd(). */
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/** The Postgres schema the Models default to. Default: `public`. */
|
|
49
|
+
schema?: string;
|
|
50
|
+
loader?: ModelsLoader;
|
|
51
|
+
/**
|
|
52
|
+
* Where historical trees materialize. Default: a fresh temp dir under the
|
|
53
|
+
* repo's node_modules — bare imports (`@everystack/model`) must resolve
|
|
54
|
+
* from the materialized files, and node walks parent directories.
|
|
55
|
+
*/
|
|
56
|
+
materializeRoot?: string;
|
|
57
|
+
/** The ref that must descend from the declaring commit. Default: HEAD. */
|
|
58
|
+
headRef?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DescentVerdict =
|
|
62
|
+
/** Not a git checkout (or no commits yet) — the rule cannot be verified here. */
|
|
63
|
+
| { status: 'no-git' }
|
|
64
|
+
/** The target has no tables — nothing to protect, first apply bootstraps it. */
|
|
65
|
+
| { status: 'fresh-target' }
|
|
66
|
+
/** Fast-forward: `commit` declares the target's live state and is an ancestor of HEAD. */
|
|
67
|
+
| { status: 'ok'; commit: string; tree: string; scannedTrees: number }
|
|
68
|
+
/** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
|
|
69
|
+
| { status: 'diverged'; commits: string[]; tree: string; reason: string }
|
|
70
|
+
/** No committed models state declares the live fingerprint — operator decision. */
|
|
71
|
+
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string };
|
|
72
|
+
|
|
73
|
+
export interface ModelTreeCandidate {
|
|
74
|
+
/** The models directory's tree oid — the identity of a declared state. */
|
|
75
|
+
tree: string;
|
|
76
|
+
/** Every enumerated commit carrying that tree, newest first. */
|
|
77
|
+
commits: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const short = (ref: string): string => ref.slice(0, 12);
|
|
81
|
+
|
|
82
|
+
function git(args: string[], cwd: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const defaultLoader: ModelsLoader = async (barrel) => {
|
|
91
|
+
const mod: any = await import(pathToFileURL(path.resolve(barrel)).href);
|
|
92
|
+
const models = mod.models ?? mod.default;
|
|
93
|
+
if (!Array.isArray(models)) throw new Error(`${barrel} does not export a \`models\` array`);
|
|
94
|
+
return models;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every historical state of the models directory across ALL refs, newest
|
|
99
|
+
* first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
|
|
100
|
+
* (no simplification dropping a state introduced on a side branch); one
|
|
101
|
+
* `cat-file --batch-check` resolves all `<sha>:<dir>` specs in a single
|
|
102
|
+
* child process, in input order.
|
|
103
|
+
*/
|
|
104
|
+
export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCandidate[] {
|
|
105
|
+
const out = git(['rev-list', '--all', '--full-history', '--date-order', '--', modelsDir], cwd);
|
|
106
|
+
if (out === null) return [];
|
|
107
|
+
const shas = out.trim().split('\n').filter(Boolean);
|
|
108
|
+
if (shas.length === 0) return [];
|
|
109
|
+
|
|
110
|
+
const specs = shas.map((sha) => (modelsDir === '.' ? `${sha}^{tree}` : `${sha}:${modelsDir}`));
|
|
111
|
+
let batch: string;
|
|
112
|
+
try {
|
|
113
|
+
batch = execFileSync('git', ['cat-file', '--batch-check'], {
|
|
114
|
+
cwd,
|
|
115
|
+
input: specs.join('\n') + '\n',
|
|
116
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
117
|
+
}).toString();
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const byTree = new Map<string, string[]>();
|
|
123
|
+
const order: string[] = [];
|
|
124
|
+
batch.trim().split('\n').forEach((line, i) => {
|
|
125
|
+
const m = line.trim().match(/^([0-9a-f]{40,64}) tree /);
|
|
126
|
+
if (!m) return; // the directory is absent at that commit, or not a tree
|
|
127
|
+
if (!byTree.has(m[1])) {
|
|
128
|
+
byTree.set(m[1], []);
|
|
129
|
+
order.push(m[1]);
|
|
130
|
+
}
|
|
131
|
+
byTree.get(m[1])!.push(shas[i]);
|
|
132
|
+
});
|
|
133
|
+
return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Extract a tree into `dest` — `git archive` piped to tar, no shell. */
|
|
137
|
+
export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
138
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
139
|
+
const archive = execFileSync('git', ['archive', '--format=tar', tree], {
|
|
140
|
+
cwd,
|
|
141
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
142
|
+
});
|
|
143
|
+
execFileSync('tar', ['-xf', '-', '-C', dest], { input: archive });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The fast-forward rule. Given the target's live state (already
|
|
148
|
+
* introspected — the apply path has it in hand), find the commit whose
|
|
149
|
+
* committed models declare that state and verify the checkout descends
|
|
150
|
+
* from it. Any declaring ancestor satisfies the rule; `diverged` is
|
|
151
|
+
* reported only when NO tree declaring the state has one.
|
|
152
|
+
*/
|
|
153
|
+
export async function verifyDescent(
|
|
154
|
+
modelsPath: string,
|
|
155
|
+
live: LiveState,
|
|
156
|
+
opts: DescentOptions = {},
|
|
157
|
+
): Promise<DescentVerdict> {
|
|
158
|
+
if (live.snapshot.tables.length === 0) return { status: 'fresh-target' };
|
|
159
|
+
|
|
160
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
161
|
+
const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
|
|
162
|
+
if (rootOut === null) return { status: 'no-git' };
|
|
163
|
+
const repoRoot = rootOut.trim();
|
|
164
|
+
const headOut = git(['rev-parse', opts.headRef ?? 'HEAD'], cwd);
|
|
165
|
+
if (headOut === null) return { status: 'no-git' };
|
|
166
|
+
const head = headOut.trim();
|
|
167
|
+
|
|
168
|
+
// realpath the cwd side: `--show-toplevel` resolves symlinks (macOS tmpdir
|
|
169
|
+
// is /var → /private/var), and a mismatched prefix would walk the models
|
|
170
|
+
// path right out of the repo.
|
|
171
|
+
const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
|
|
172
|
+
const rel = path.relative(repoRoot, modelsAbs);
|
|
173
|
+
const modelsDir = path.dirname(rel) || '.';
|
|
174
|
+
const barrel = path.basename(modelsAbs);
|
|
175
|
+
|
|
176
|
+
const candidates = enumerateModelTrees(modelsDir, repoRoot);
|
|
177
|
+
const liveFingerprint = fingerprintLive(live.snapshot, live.contract).hash;
|
|
178
|
+
|
|
179
|
+
const nodeModules = path.join(repoRoot, 'node_modules');
|
|
180
|
+
const materializeRoot = opts.materializeRoot
|
|
181
|
+
?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
|
|
182
|
+
const ownsRoot = !opts.materializeRoot;
|
|
183
|
+
const loader = opts.loader ?? defaultLoader;
|
|
184
|
+
|
|
185
|
+
const compileFailures: string[] = [];
|
|
186
|
+
const declaringButDiverged: ModelTreeCandidate[] = [];
|
|
187
|
+
try {
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
const dest = path.join(materializeRoot, candidate.tree);
|
|
190
|
+
let predicted: string;
|
|
191
|
+
try {
|
|
192
|
+
if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
|
|
193
|
+
const models = await loader(path.join(dest, barrel));
|
|
194
|
+
predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (predicted !== liveFingerprint) continue;
|
|
200
|
+
|
|
201
|
+
for (const commit of candidate.commits) {
|
|
202
|
+
try {
|
|
203
|
+
execFileSync('git', ['merge-base', '--is-ancestor', commit, head], {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
stdio: 'ignore',
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
status: 'ok',
|
|
209
|
+
commit,
|
|
210
|
+
tree: candidate.tree,
|
|
211
|
+
scannedTrees: candidates.indexOf(candidate) + 1,
|
|
212
|
+
};
|
|
213
|
+
} catch {
|
|
214
|
+
// not an ancestor — keep looking; an identical tree elsewhere may be
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
declaringButDiverged.push(candidate);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (declaringButDiverged.length > 0) {
|
|
224
|
+
const commits = declaringButDiverged.flatMap((c) => c.commits);
|
|
225
|
+
return {
|
|
226
|
+
status: 'diverged',
|
|
227
|
+
commits,
|
|
228
|
+
tree: declaringButDiverged[0].tree,
|
|
229
|
+
reason:
|
|
230
|
+
`the target's state is declared by ${short(commits[0])}` +
|
|
231
|
+
(commits.length > 1 ? ` (+${commits.length - 1} more)` : '') +
|
|
232
|
+
', which is NOT an ancestor of your checkout — your branch diverged before it. ' +
|
|
233
|
+
'Rebase onto it and re-mint, so your edge moves the target forward instead of reverting merged work.',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const failureNote = compileFailures.length > 0
|
|
238
|
+
? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
|
|
239
|
+
: '';
|
|
240
|
+
return {
|
|
241
|
+
status: 'drift',
|
|
242
|
+
scannedTrees: candidates.length,
|
|
243
|
+
compileFailures,
|
|
244
|
+
reason:
|
|
245
|
+
`no committed models state declares the target's live fingerprint ${short(liveFingerprint)} — ` +
|
|
246
|
+
`scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
|
|
247
|
+
'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
|
|
248
|
+
'Align the models with the live database first (db:pull, commit), ' +
|
|
249
|
+
'or force with ceremony: --force-descent <snapshot-ref> --confirm.',
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { dbSyncCommand } from './commands/db-sync.js';
|
|
|
17
17
|
import { dbDiffCommand } from './commands/db-diff.js';
|
|
18
18
|
import { dbPlanCommand } from './commands/db-plan.js';
|
|
19
19
|
import { dbApplyCommand } from './commands/db-apply.js';
|
|
20
|
+
import { dbCheckCommand } from './commands/db-check.js';
|
|
21
|
+
import { dbApproversCommand } from './commands/db-approvers.js';
|
|
20
22
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
21
23
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
22
24
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -196,6 +198,12 @@ async function main() {
|
|
|
196
198
|
case 'db:apply':
|
|
197
199
|
await dbApplyCommand(flags);
|
|
198
200
|
break;
|
|
201
|
+
case 'db:check':
|
|
202
|
+
await dbCheckCommand(flags);
|
|
203
|
+
break;
|
|
204
|
+
case 'db:approvers':
|
|
205
|
+
await dbApproversCommand(flags);
|
|
206
|
+
break;
|
|
199
207
|
case 'db:authz:pull':
|
|
200
208
|
await dbAuthzPullCommand(flags);
|
|
201
209
|
break;
|
|
@@ -323,7 +331,9 @@ Usage:
|
|
|
323
331
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
324
332
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
325
333
|
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
|
326
|
-
everystack db:apply --plan <file.plan.json> [--database-url <url>] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock),
|
|
334
|
+
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
|
|
335
|
+
everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
336
|
+
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
327
337
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
328
338
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
329
339
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -304,6 +304,11 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
304
304
|
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
305
305
|
pgCoreBuilders.add(pgBuilder);
|
|
306
306
|
const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
|
|
307
|
+
if (spec.isDeprecated) {
|
|
308
|
+
// The contract phase (decision 13): the column stays in the database and
|
|
309
|
+
// stays readable; the strikethrough warns new code away at author time.
|
|
310
|
+
colLines.push(` /** @deprecated contract phase — readable, not writable; the physical drop is a later, gated act. */`);
|
|
311
|
+
}
|
|
307
312
|
colLines.push(` ${key}: ${call}${mods},`);
|
|
308
313
|
}
|
|
309
314
|
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -56,6 +56,36 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
|
|
|
56
56
|
return { executable, heldDrops, notices };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The destructive subset of an executable statement stream — destructive
|
|
61
|
+
* means DATA LOSS (brick 9's classification, design decision 11):
|
|
62
|
+
*
|
|
63
|
+
* drops — `DROP TABLE/COLUMN/TYPE`: the data is gone.
|
|
64
|
+
* narrowings — lossy/risky `ALTER COLUMN … SET DATA TYPE`: data loss
|
|
65
|
+
* wearing an ALTER. Detection rides the emitter's structural
|
|
66
|
+
* contract, not prose: a SAFE type change is a bare
|
|
67
|
+
* `SET DATA TYPE`; anything lossy or risky carries a `USING`
|
|
68
|
+
* cast (schema-diff's `classifyTypeChange`, conservative by
|
|
69
|
+
* default — the contract is pinned by test).
|
|
70
|
+
*
|
|
71
|
+
* Recoverable metadata is never destructive: policies, grants, constraints,
|
|
72
|
+
* defaults, nullability all re-declare from the models without touching a row.
|
|
73
|
+
*/
|
|
74
|
+
export interface DestructiveBreakdown {
|
|
75
|
+
drops: string[];
|
|
76
|
+
narrowings: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function classifyDestructive(executable: string[]): DestructiveBreakdown {
|
|
80
|
+
const drops: string[] = [];
|
|
81
|
+
const narrowings: string[] = [];
|
|
82
|
+
for (const statement of executable) {
|
|
83
|
+
if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
|
|
84
|
+
else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
|
|
85
|
+
}
|
|
86
|
+
return { drops, narrowings };
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
/** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
|
|
60
90
|
export function currentGitRef(): string | null {
|
|
61
91
|
try {
|