@everystack/cli 0.3.13 → 0.3.15

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * derived-source — the source side of the derived-object reconciler.
3
+ *
4
+ * Parses an app's ordered db/sql files into structured derived objects
5
+ * (functions, views, materialized views), attaching the statements that ride
6
+ * with an object (its indexes, comments, grants) and hashing each object's
7
+ * normalized content. The reconciler diffs these hashes against the live
8
+ * catalog's provenance to decide what to create, replace, or drop.
9
+ *
10
+ * Everything here is pure and database-free. The lexer respects Postgres
11
+ * string syntax — single quotes with '' doubling, E-strings with backslash
12
+ * escapes, double-quoted identifiers, tagged dollar quoting, and nested block
13
+ * comments — because a matview body is exactly where naive splitting dies.
14
+ *
15
+ * Identity note: functions are identified by schema-qualified NAME, not full
16
+ * signature. Overloads are rare in app SQL layers; when they appear we warn
17
+ * rather than mis-model them (the honest limitation, revisit if a consumer
18
+ * actually needs overloads).
19
+ */
20
+
21
+ import { createHash } from 'node:crypto';
22
+
23
+ export type DerivedKind = 'function' | 'view' | 'materialized view';
24
+
25
+ /** A statement that belongs to an object: its index, comment, or grant. */
26
+ export interface Attachment {
27
+ kind: 'index' | 'comment' | 'grant';
28
+ sql: string;
29
+ }
30
+
31
+ export interface SourceObject {
32
+ kind: DerivedKind;
33
+ /** Schema name, defaulted to `public` when unqualified. */
34
+ schema: string;
35
+ name: string;
36
+ /** `schema.name` — the reconciler's join key against the live catalog. */
37
+ identity: string;
38
+ /** The CREATE statement as authored (no trailing semicolon). */
39
+ sql: string;
40
+ /** Indexes / comments / grants targeting this object, in source order. */
41
+ attachments: Attachment[];
42
+ /** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
43
+ hash: string;
44
+ /** Source file this object came from. */
45
+ file: string;
46
+ /** Position in the concatenated source — a valid dependency order by convention. */
47
+ seq: number;
48
+ }
49
+
50
+ export interface SourceFile {
51
+ file: string;
52
+ sql: string;
53
+ }
54
+
55
+ export interface ParsedSources {
56
+ objects: SourceObject[];
57
+ /** Statements the reconciler does not manage — surfaced, never silently dropped. */
58
+ warnings: string[];
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Lexer.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ type LexState =
66
+ | { in: 'normal' }
67
+ | { in: 'single'; escaped: boolean }
68
+ | { in: 'double' }
69
+ | { in: 'dollar'; tag: string }
70
+ | { in: 'line-comment' }
71
+ | { in: 'block-comment'; depth: number };
72
+
73
+ /** Matches a dollar-quote opener at position i; returns the full tag (`$$`, `$body$`) or null. */
74
+ function dollarTagAt(text: string, i: number): string | null {
75
+ if (text[i] !== '$') return null;
76
+ const m = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(i, i + 64));
77
+ return m ? m[0] : null;
78
+ }
79
+
80
+ /**
81
+ * Walk the text, calling `emit` for every character with the state it is IN
82
+ * (comments/strings/normal). The shared core under both the statement splitter
83
+ * and the normalizer, so they can never disagree about where a string ends.
84
+ */
85
+ function lex(
86
+ text: string,
87
+ emit: (ch: string, state: LexState['in'], index: number) => void,
88
+ ): void {
89
+ let state: LexState = { in: 'normal' };
90
+ let i = 0;
91
+ while (i < text.length) {
92
+ const ch = text[i];
93
+ const next = text[i + 1];
94
+
95
+ if (state.in === 'normal') {
96
+ if (ch === '-' && next === '-') {
97
+ state = { in: 'line-comment' };
98
+ emit(ch, 'line-comment', i); i++;
99
+ emit(next, 'line-comment', i); i++;
100
+ continue;
101
+ }
102
+ if (ch === '/' && next === '*') {
103
+ state = { in: 'block-comment', depth: 1 };
104
+ emit(ch, 'block-comment', i); i++;
105
+ emit(next, 'block-comment', i); i++;
106
+ continue;
107
+ }
108
+ const tag = dollarTagAt(text, i);
109
+ if (tag) {
110
+ state = { in: 'dollar', tag };
111
+ for (const c of tag) { emit(c, 'dollar', i); i++; }
112
+ continue;
113
+ }
114
+ if (ch === "'") {
115
+ // E'...' / e'...' strings give backslash escape semantics.
116
+ const prev = text[i - 1];
117
+ state = { in: 'single', escaped: prev === 'E' || prev === 'e' };
118
+ emit(ch, 'single', i); i++;
119
+ continue;
120
+ }
121
+ if (ch === '"') {
122
+ state = { in: 'double' };
123
+ emit(ch, 'double', i); i++;
124
+ continue;
125
+ }
126
+ emit(ch, 'normal', i); i++;
127
+ continue;
128
+ }
129
+
130
+ if (state.in === 'line-comment') {
131
+ emit(ch, 'line-comment', i); i++;
132
+ if (ch === '\n') state = { in: 'normal' };
133
+ continue;
134
+ }
135
+
136
+ if (state.in === 'block-comment') {
137
+ if (ch === '/' && next === '*') {
138
+ state = { in: 'block-comment', depth: state.depth + 1 };
139
+ emit(ch, 'block-comment', i); i++;
140
+ emit(next, 'block-comment', i); i++;
141
+ continue;
142
+ }
143
+ if (ch === '*' && next === '/') {
144
+ const newDepth: number = state.depth - 1;
145
+ emit(ch, 'block-comment', i); i++;
146
+ emit(next, 'block-comment', i); i++;
147
+ state = newDepth === 0 ? { in: 'normal' } : { in: 'block-comment', depth: newDepth };
148
+ continue;
149
+ }
150
+ emit(ch, 'block-comment', i); i++;
151
+ continue;
152
+ }
153
+
154
+ if (state.in === 'single') {
155
+ if (state.escaped && ch === '\\') {
156
+ emit(ch, 'single', i); i++;
157
+ if (i < text.length) { emit(text[i], 'single', i); i++; }
158
+ continue;
159
+ }
160
+ if (ch === "'" && next === "'") {
161
+ emit(ch, 'single', i); i++;
162
+ emit(next, 'single', i); i++;
163
+ continue;
164
+ }
165
+ emit(ch, 'single', i); i++;
166
+ if (ch === "'") state = { in: 'normal' };
167
+ continue;
168
+ }
169
+
170
+ if (state.in === 'double') {
171
+ if (ch === '"' && next === '"') {
172
+ emit(ch, 'double', i); i++;
173
+ emit(next, 'double', i); i++;
174
+ continue;
175
+ }
176
+ emit(ch, 'double', i); i++;
177
+ if (ch === '"') state = { in: 'normal' };
178
+ continue;
179
+ }
180
+
181
+ // dollar
182
+ if (ch === '$') {
183
+ const tag = dollarTagAt(text, i);
184
+ if (tag === state.tag) {
185
+ for (const c of tag) { emit(c, 'dollar', i); i++; }
186
+ state = { in: 'normal' };
187
+ continue;
188
+ }
189
+ }
190
+ emit(ch, 'dollar', i); i++;
191
+ }
192
+ }
193
+
194
+ /** Split SQL text into statements on top-level semicolons. Statements come back trimmed. */
195
+ export function splitSqlStatements(text: string): string[] {
196
+ const statements: string[] = [];
197
+ let current = '';
198
+ lex(text, (ch, state) => {
199
+ if (state === 'normal' && ch === ';') {
200
+ const s = current.trim();
201
+ if (s) statements.push(s);
202
+ current = '';
203
+ return;
204
+ }
205
+ current += ch;
206
+ });
207
+ const last = current.trim();
208
+ if (last) statements.push(last);
209
+ return statements;
210
+ }
211
+
212
+ const WORDISH = /[A-Za-z0-9_$]/;
213
+
214
+ /**
215
+ * Normalize SQL for content hashing: comments stripped; outside strings,
216
+ * whitespace survives only as a single space between word tokens (`SELECT a`),
217
+ * never around punctuation (`a,b` ≡ `a, b`). A renumbered header comment or a
218
+ * reflowed SELECT hashes identically; any change to the actual SQL does not.
219
+ * The output is a canonical hashing form, not necessarily runnable SQL.
220
+ */
221
+ export function normalizeSql(sql: string): string {
222
+ let out = '';
223
+ let pendingSpace = false;
224
+ lex(sql, (ch, state) => {
225
+ if (state === 'line-comment' || state === 'block-comment') return;
226
+ if (state === 'normal' && /\s/.test(ch)) {
227
+ pendingSpace = true;
228
+ return;
229
+ }
230
+ if (pendingSpace) {
231
+ if (out.length > 0 && WORDISH.test(out[out.length - 1]) && WORDISH.test(ch)) out += ' ';
232
+ pendingSpace = false;
233
+ }
234
+ out += ch;
235
+ });
236
+ return out.trim();
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Classification.
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /** Strip quotes from an identifier and split an optionally schema-qualified name. */
244
+ function parseQualified(raw: string): { schema: string; name: string } {
245
+ const parts: string[] = [];
246
+ let current = '';
247
+ let quoted = false;
248
+ for (let i = 0; i < raw.length; i++) {
249
+ const ch = raw[i];
250
+ if (ch === '"') {
251
+ if (quoted && raw[i + 1] === '"') { current += '"'; i++; continue; }
252
+ quoted = !quoted;
253
+ continue;
254
+ }
255
+ if (ch === '.' && !quoted) { parts.push(current); current = ''; continue; }
256
+ current += ch;
257
+ }
258
+ parts.push(current);
259
+ if (parts.length >= 2) return { schema: parts[0], name: parts.slice(1).join('.') };
260
+ return { schema: 'public', name: parts[0] };
261
+ }
262
+
263
+ const FUNCTION_RE = /^CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+((?:"[^"]*"|[^\s(])+)\s*\(/i;
264
+ const VIEW_RE = /^CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+((?:"[^"]*"|[^\s(])+)/i;
265
+ const MATVIEW_RE = /^CREATE\s+MATERIALIZED\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:"[^"]*"|[^\s(])+)/i;
266
+ const INDEX_RE = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:(?:"[^"]*"|[^\s(])+\s+)?ON\s+(?:ONLY\s+)?((?:"[^"]*"|[^\s(])+)/i;
267
+ const COMMENT_RE = /^COMMENT\s+ON\s+(MATERIALIZED\s+VIEW|VIEW|FUNCTION|COLUMN)\s+((?:"[^"]*"|[^\s(,])+)/i;
268
+ const GRANT_RE = /^GRANT\s+[\s\S]*?\bON\s+(?:TABLE\s+)?((?:"[^"]*"|[^\s(,])+)/i;
269
+
270
+ /**
271
+ * The first non-comment text of a statement, whitespace collapsed but token
272
+ * boundaries preserved (unlike the aggressive hash form — `VIEW "MyView"` must
273
+ * keep its space for the classification regexes).
274
+ */
275
+ function statementHead(statement: string): string {
276
+ let out = '';
277
+ lex(statement, (ch, state) => {
278
+ if (state === 'line-comment' || state === 'block-comment') return;
279
+ if (state === 'normal' && /\s/.test(ch)) {
280
+ if (out.length > 0 && !out.endsWith(' ')) out += ' ';
281
+ return;
282
+ }
283
+ out += ch;
284
+ });
285
+ return out.trim().slice(0, 400);
286
+ }
287
+
288
+ /**
289
+ * Parse ordered source files into derived objects. Attachments (indexes,
290
+ * comments, grants) join the object they target; statements that are neither a
291
+ * derived object nor an attachment become warnings — the reconciler manages
292
+ * the derived layer only, and it says so out loud.
293
+ */
294
+ export function parseSqlSources(files: SourceFile[]): ParsedSources {
295
+ const objects: SourceObject[] = [];
296
+ const warnings: string[] = [];
297
+ const byIdentity = new Map<string, SourceObject>();
298
+ let seq = 0;
299
+
300
+ const identityOf = (raw: string): { schema: string; name: string; identity: string } => {
301
+ const { schema, name } = parseQualified(raw);
302
+ return { schema, name, identity: `${schema}.${name}` };
303
+ };
304
+
305
+ for (const { file, sql } of files) {
306
+ for (const statement of splitSqlStatements(sql)) {
307
+ const head = statementHead(statement);
308
+
309
+ const asObject = (kind: DerivedKind, rawName: string): void => {
310
+ const { schema, name, identity } = identityOf(rawName);
311
+ if (byIdentity.has(identity)) {
312
+ warnings.push(
313
+ `${file}: duplicate definition for ${identity} — function overloads are not modeled; ` +
314
+ `the reconciler tracks one definition per name (last one wins).`,
315
+ );
316
+ }
317
+ const obj: SourceObject = {
318
+ kind, schema, name, identity,
319
+ sql: statement, attachments: [], hash: '', file, seq: seq++,
320
+ };
321
+ byIdentity.set(identity, obj);
322
+ objects.push(obj);
323
+ };
324
+
325
+ const attach = (kind: Attachment['kind'], rawTarget: string, stripColumn = false): boolean => {
326
+ let { identity } = identityOf(rawTarget);
327
+ if (stripColumn) {
328
+ // COMMENT ON COLUMN schema.obj.col — the owning object is one segment up.
329
+ const parts = identity.split('.');
330
+ identity = parts.length > 2 ? parts.slice(0, -1).join('.') : `public.${parts[0]}`;
331
+ }
332
+ const target = byIdentity.get(identity);
333
+ if (!target) return false;
334
+ target.attachments.push({ kind, sql: statement });
335
+ return true;
336
+ };
337
+
338
+ let m: RegExpExecArray | null;
339
+ if ((m = FUNCTION_RE.exec(head))) { asObject('function', m[1]); continue; }
340
+ if ((m = MATVIEW_RE.exec(head))) { asObject('materialized view', m[1]); continue; }
341
+ if ((m = VIEW_RE.exec(head))) { asObject('view', m[1]); continue; }
342
+
343
+ if ((m = INDEX_RE.exec(head))) {
344
+ if (!attach('index', m[1])) {
345
+ warnings.push(`${file}: CREATE INDEX targets ${m[1]}, which is not a derived object in these sources — not managed here (base-table indexes belong to the model layer).`);
346
+ }
347
+ continue;
348
+ }
349
+ if ((m = COMMENT_RE.exec(head))) {
350
+ const isColumn = /^COLUMN$/i.test(m[1].replace(/\s+/g, ' ').split(' ')[0]) || /^COLUMN/i.test(m[1]);
351
+ if (!attach('comment', m[2], isColumn)) {
352
+ warnings.push(`${file}: COMMENT ON targets ${m[2]}, which is not a derived object in these sources.`);
353
+ }
354
+ continue;
355
+ }
356
+ if ((m = GRANT_RE.exec(head))) {
357
+ if (!attach('grant', m[1])) {
358
+ warnings.push(`${file}: GRANT targets ${m[1]}, which is not a derived object in these sources.`);
359
+ }
360
+ continue;
361
+ }
362
+
363
+ warnings.push(`${file}: unmanaged statement (${head.slice(0, 60)}…) — the reconciler manages functions, views, and materialized views; move this to the appropriate layer.`);
364
+ }
365
+ }
366
+
367
+ for (const obj of objects) {
368
+ const content = [normalizeSql(obj.sql), ...obj.attachments.map((a) => normalizeSql(a.sql))].join('\n');
369
+ obj.hash = createHash('sha256').update(content).digest('hex');
370
+ }
371
+
372
+ return { objects, warnings };
373
+ }
package/src/cli/index.ts CHANGED
@@ -11,6 +11,7 @@ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDocto
11
11
  import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz.js';
12
12
  import { dbGenerateCommand } from './commands/db-generate.js';
13
13
  import { dbPullCommand } from './commands/db-pull.js';
14
+ import { dbReconcileCommand } from './commands/db-reconcile.js';
14
15
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
15
16
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
16
17
  import { consoleCommand } from './commands/console.js';
@@ -172,6 +173,9 @@ async function main() {
172
173
  case 'db:pull':
173
174
  await dbPullCommand(flags);
174
175
  break;
176
+ case 'db:reconcile':
177
+ await dbReconcileCommand(flags);
178
+ break;
175
179
  case 'db:authz:pull':
176
180
  await dbAuthzPullCommand(flags);
177
181
  break;
@@ -291,13 +295,14 @@ Usage:
291
295
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
292
296
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
293
297
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
294
- everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
298
+ everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
295
299
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise
296
300
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
301
+ everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently); first contact with existing objects wants --baseline, once.
297
302
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
298
303
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
299
304
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
300
- everystack db:authz:owner [--stage <name>] [--models models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
305
+ everystack db:authz:owner [--stage <name>] [--models db/models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
301
306
  everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
302
307
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
303
308
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
@@ -0,0 +1,25 @@
1
+ /**
2
+ * models-path — where the Models barrel lives.
3
+ *
4
+ * One home for schema: `db/models/` (state) beside `db/sql/` (compute). The
5
+ * CLI looks there first, falling back to the legacy top-level `models/` so
6
+ * existing consumers keep working without a flag. An explicit `--models`
7
+ * always wins. When neither home exists, the NEW home is returned so error
8
+ * messages point people at the current convention, not the legacy one.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /** Candidate barrels, in preference order. */
14
+ export const MODELS_HOMES = ['db/models/index.ts', 'models/index.ts'] as const;
15
+
16
+ export function resolveModelsPath(
17
+ flag?: string,
18
+ exists: (p: string) => boolean = existsSync,
19
+ ): string {
20
+ if (flag) return flag;
21
+ for (const home of MODELS_HOMES) {
22
+ if (exists(home)) return home;
23
+ }
24
+ return MODELS_HOMES[0];
25
+ }
@@ -102,10 +102,12 @@ export function detectProjectReality(root: string): ProjectReality {
102
102
  ? 'V2'
103
103
  : 'V1';
104
104
 
105
- const modelsDir = path.join(abs, 'models');
105
+ // One home for schema: db/models is the current convention, top-level models the legacy one.
106
+ const modelsHome = isDir(path.join(abs, 'db', 'models')) ? 'db/models' : 'models';
107
+ const modelsDir = path.join(abs, modelsHome);
106
108
  const modelFiles = listTs(modelsDir).filter((f) => f !== 'index.ts');
107
109
  const models = {
108
- dir: isDir(modelsDir) ? 'models' : null,
110
+ dir: isDir(modelsDir) ? modelsHome : null,
109
111
  files: modelFiles,
110
112
  hasIndex: isFile(path.join(modelsDir, 'index.ts')),
111
113
  };