@everystack/cli 0.3.31 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +71 -20
- package/src/cli/commands/db-pull.ts +37 -5
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +4 -1
package/src/cli/model-render.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
|
|
19
|
+
import type { DerivedRenderResult } from './derived-render.js';
|
|
19
20
|
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -59,6 +60,47 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
|
|
|
59
60
|
export interface RenderOptions {
|
|
60
61
|
/** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
|
|
61
62
|
schema?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The read-model scaffold. Default `'commented'`: every model carries the authz decision
|
|
65
|
+
* as a commented stanza (same guidance as the db:check gate — the model fails the gate
|
|
66
|
+
* until it is authored). A preset name (`'public-read'`) stamps that stanza uncommented
|
|
67
|
+
* into every rendered model — an explicit, one-shot, operator-invoked choice that lands
|
|
68
|
+
* as reviewable code. Grants are authored, never inherited; there is no runtime default.
|
|
69
|
+
*/
|
|
70
|
+
abilities?: string;
|
|
71
|
+
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
72
|
+
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
73
|
+
derived?: DerivedRenderResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `--abilities` presets db:pull can stamp. Deliberately few: `public-read` is the
|
|
78
|
+
* dominant brownfield shape (public reference data, admin-managed). Anything else is a
|
|
79
|
+
* per-model edit — the decision belongs in the file, not in a flag grammar.
|
|
80
|
+
*/
|
|
81
|
+
export const ABILITY_PRESETS: Record<string, string> = {
|
|
82
|
+
'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The scaffold stanza for one model: the commented decision surface (guide text says the
|
|
87
|
+
* same thing the db:check gate failure says — one voice, two doorways), or a preset
|
|
88
|
+
* stamped uncommented. An unknown preset throws — grants are authored, never guessed.
|
|
89
|
+
*/
|
|
90
|
+
function abilitiesStanza(mode: string): string {
|
|
91
|
+
if (mode === 'commented') {
|
|
92
|
+
return [
|
|
93
|
+
' // Declare the read model — db:check fails this model until its authz is authored:',
|
|
94
|
+
` // abilities: [can('read')], // public data`,
|
|
95
|
+
` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
|
|
96
|
+
' // private: true, // not part of the data API',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
const preset = ABILITY_PRESETS[mode];
|
|
100
|
+
if (!preset) {
|
|
101
|
+
throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
|
|
102
|
+
}
|
|
103
|
+
return ` ${preset}`;
|
|
62
104
|
}
|
|
63
105
|
|
|
64
106
|
/** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
|
|
@@ -210,8 +252,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
210
252
|
|
|
211
253
|
if (col.default != null && !serial) {
|
|
212
254
|
const d = renderDefault(col.default);
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
// No builder maps it → declare it verbatim with the escape hatch. RAW, not the
|
|
256
|
+
// normalized form — stripping a trailing cast here could change semantics
|
|
257
|
+
// (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
|
|
258
|
+
// Drift closed by construction: pull output converges instead of shedding a TODO.
|
|
259
|
+
expr += d ?? `.defaultSql(sql\`${col.default}\`)`;
|
|
215
260
|
}
|
|
216
261
|
|
|
217
262
|
const validate = validates.get(col.name);
|
|
@@ -236,8 +281,14 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
236
281
|
items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
|
|
237
282
|
}
|
|
238
283
|
for (const ix of table.indexes) {
|
|
239
|
-
|
|
284
|
+
// Plain entries render as field keys; canonical-text entries (expressions, DESC,
|
|
285
|
+
// opclass) render as raw sql`` args — the same form the builder declares (Brick E).
|
|
286
|
+
const entry = (c: string): string =>
|
|
287
|
+
/^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${c}\``;
|
|
288
|
+
let s = `index(${ix.columns.map(entry).join(', ')})`;
|
|
289
|
+
if (ix.using) s += `.using(${tsLiteral(ix.using)})`;
|
|
240
290
|
if (ix.unique) s += '.unique()';
|
|
291
|
+
if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
|
|
241
292
|
if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
|
|
242
293
|
items.push(s);
|
|
243
294
|
}
|
|
@@ -261,7 +312,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
261
312
|
}
|
|
262
313
|
|
|
263
314
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
264
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string {
|
|
315
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented'): string {
|
|
265
316
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
266
317
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
267
318
|
const validates = new Map<string, string>();
|
|
@@ -275,23 +326,52 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
275
326
|
|
|
276
327
|
const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
|
|
277
328
|
const constraints = renderConstraintsBlock(table, tableChecks, known);
|
|
329
|
+
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
330
|
+
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
331
|
+
// put it, proving the position is mechanical-edit-friendly).
|
|
332
|
+
const stanza = abilitiesStanza(abilities);
|
|
278
333
|
|
|
279
|
-
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
334
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
280
335
|
}
|
|
281
336
|
|
|
282
337
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
283
|
-
function importHeader(body: string): string {
|
|
338
|
+
function importHeader(body: string, extra: string[] = []): string {
|
|
284
339
|
const symbols = ['defineModel', 'field'];
|
|
340
|
+
// `can` only when a stanza is stamped UNCOMMENTED (line starts with spaces, not `//`) —
|
|
341
|
+
// the commented scaffold must not leave an unused import behind.
|
|
342
|
+
if (/^\s*abilities: \[can\(/m.test(body)) symbols.push('can');
|
|
285
343
|
if (/\bunique\(/.test(body)) symbols.push('unique');
|
|
286
344
|
if (/\bcheck\(/.test(body)) symbols.push('check');
|
|
287
345
|
if (/\bindex\(/.test(body)) symbols.push('index');
|
|
288
346
|
if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
|
|
347
|
+
if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
|
|
289
348
|
if (/\bsql`/.test(body)) symbols.push('sql');
|
|
349
|
+
for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
|
|
290
350
|
const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
|
|
291
351
|
if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
|
|
292
352
|
return imports.join('\n');
|
|
293
353
|
}
|
|
294
354
|
|
|
355
|
+
/** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
|
|
356
|
+
function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multiline = false): string {
|
|
357
|
+
const models = multiline
|
|
358
|
+
? `export const models = [\n${modelNames.map((n) => ` ${n},`).join('\n')}\n];`
|
|
359
|
+
: `export const models = [${modelNames.join(', ')}];`;
|
|
360
|
+
const parts = [models];
|
|
361
|
+
const keys = ['models'];
|
|
362
|
+
if (derived?.sequenceNames.length) {
|
|
363
|
+
parts.push(`export const sequences = [${derived.sequenceNames.join(', ')}];`);
|
|
364
|
+
keys.push('sequences');
|
|
365
|
+
}
|
|
366
|
+
if (derived?.names.length) {
|
|
367
|
+
parts.push(`export const derived = [${derived.names.join(', ')}];`);
|
|
368
|
+
keys.push('derived');
|
|
369
|
+
}
|
|
370
|
+
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
371
|
+
parts.push(`export const modules = [appModule];`);
|
|
372
|
+
return parts.join('\n\n');
|
|
373
|
+
}
|
|
374
|
+
|
|
295
375
|
/**
|
|
296
376
|
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
297
377
|
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
@@ -303,11 +383,16 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
303
383
|
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
304
384
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
305
385
|
|
|
306
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums));
|
|
307
|
-
|
|
386
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented'));
|
|
387
|
+
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
388
|
+
// reference, before the module wrapper that composes all three.
|
|
389
|
+
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
390
|
+
|
|
391
|
+
// Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
|
|
392
|
+
// so a fresh brownfield project lands on the same shape the framework composes.
|
|
393
|
+
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
|
|
308
394
|
|
|
309
|
-
const header = importHeader(
|
|
310
|
-
const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
|
|
395
|
+
const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
|
|
311
396
|
|
|
312
397
|
return [header, ...blocks, footer].join('\n\n') + '\n';
|
|
313
398
|
}
|
|
@@ -349,7 +434,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
349
434
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
350
435
|
|
|
351
436
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
352
|
-
const block = renderModelBlock(t, known, enums);
|
|
437
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented');
|
|
353
438
|
const crossImports = referencedTables(t, known).map(
|
|
354
439
|
(target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
|
|
355
440
|
);
|
|
@@ -357,11 +442,19 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
357
442
|
return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
|
|
358
443
|
});
|
|
359
444
|
|
|
445
|
+
// The index carries the module wrapper (composition is the norm) and the derived layer
|
|
446
|
+
// (B5 — it references the model variables the index imports); model files carry only
|
|
447
|
+
// their own model.
|
|
360
448
|
const names = tables.map((t) => modelVarName(t.table));
|
|
449
|
+
const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
|
|
361
450
|
const index = [
|
|
362
|
-
|
|
451
|
+
[
|
|
452
|
+
`import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
|
|
453
|
+
...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
|
|
454
|
+
].join('\n'),
|
|
363
455
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
364
|
-
|
|
456
|
+
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
457
|
+
moduleFooter(names, opts.derived, true),
|
|
365
458
|
].join('\n\n');
|
|
366
459
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
367
460
|
|
package/src/cli/models-path.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* models-path — where the Models barrel lives.
|
|
3
3
|
*
|
|
4
|
-
* One home for schema: `db/models/` (
|
|
4
|
+
* One home for schema: `db/models/` — state (tables) and compute (descriptors). The
|
|
5
5
|
* CLI looks there first, falling back to the legacy top-level `models/` so
|
|
6
6
|
* existing consumers keep working without a flag. An explicit `--models`
|
|
7
7
|
* always wins. When neither home exists, the NEW home is returned so error
|
package/src/cli/pipeline-path.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pipeline-path — where the consumer's pipeline definition lives.
|
|
3
3
|
*
|
|
4
|
-
* Beside the schema homes (`db/models/`, `db/
|
|
4
|
+
* Beside the schema homes (`db/models/`, `db/backfills/`), the
|
|
5
5
|
* pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
|
|
6
6
|
* exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
|
|
7
7
|
* always wins; when neither home exists the primary is returned so error
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { ModelDescriptor, FieldSpec } from '@everystack/model';
|
|
23
|
-
import type { TableSchema, EnumType, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
|
|
24
|
-
import type
|
|
22
|
+
import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
|
|
23
|
+
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
|
|
24
|
+
import { nextvalSequence, type RenameMap } from './schema-diff.js';
|
|
25
25
|
|
|
26
26
|
/** `authorId` -> `author_id`. SQL identifiers are snake_case. */
|
|
27
|
-
function toSnakeCase(name: string): string {
|
|
27
|
+
export function toSnakeCase(name: string): string {
|
|
28
28
|
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -48,20 +48,25 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
48
48
|
} else if (con.kind === 'check') {
|
|
49
49
|
checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
|
|
50
50
|
} else if (con.kind === 'index') {
|
|
51
|
-
|
|
51
|
+
// Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
|
|
52
|
+
// DESC, opclass) pass verbatim — they are already SQL (Brick E).
|
|
53
|
+
const cols = con.columns.map((c, i) => (con.rawPositions?.[i] ? c : toSnakeCase(c)));
|
|
52
54
|
// Disambiguate multiple indexes over the same columns — e.g. a full index plus a
|
|
53
55
|
// partial one (`familyId` and `familyId WHERE status='active'`): the name must be
|
|
54
56
|
// unique within the table for CREATE to succeed. The diff matches indexes by content
|
|
55
|
-
// (
|
|
56
|
-
// round-trip.
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// (entries + uniqueness + predicate + method + INCLUDE), never by name, so the suffix
|
|
58
|
+
// never affects the round-trip. Raw entries sanitize to identifier-safe name parts.
|
|
59
|
+
const nameParts = cols.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
|
|
60
|
+
let name = `${model.table}_${nameParts.join('_')}_index`;
|
|
61
|
+
for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${nameParts.join('_')}_index_${n}`;
|
|
59
62
|
usedIndexNames.add(name);
|
|
60
63
|
indexes.push({
|
|
61
64
|
name,
|
|
62
65
|
columns: cols,
|
|
63
66
|
unique: con.isUnique,
|
|
64
67
|
...(con.predicate ? { where: con.predicate } : {}),
|
|
68
|
+
...(con.method ? { using: con.method } : {}),
|
|
69
|
+
...(con.includeColumns?.length ? { include: con.includeColumns.map(toSnakeCase) } : {}),
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
}
|
|
@@ -209,6 +214,7 @@ function defaultExpr(spec: FieldSpec): string | null {
|
|
|
209
214
|
switch (spec.defaultKind) {
|
|
210
215
|
case 'now': return 'now()';
|
|
211
216
|
case 'random': return 'gen_random_uuid()';
|
|
217
|
+
case 'sql': return String(spec.default); // .defaultSql() — the author's SQL, verbatim
|
|
212
218
|
case 'value':
|
|
213
219
|
return spec.isArray && Array.isArray(spec.default) ? arrayLiteral(spec.default) : literal(spec.default);
|
|
214
220
|
default: return null;
|
|
@@ -251,7 +257,12 @@ export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKe
|
|
|
251
257
|
let s = `"${sqlName}" ${serial ?? pgType(spec)}`;
|
|
252
258
|
if (inlinePrimaryKey) s += ' PRIMARY KEY';
|
|
253
259
|
const def = serial ? null : defaultExpr(spec);
|
|
254
|
-
|
|
260
|
+
// A `defaultSql` nextval draws from an EXISTING sequence, possibly owned by a table
|
|
261
|
+
// created later in the same migration — held out of the column line (compileMigration
|
|
262
|
+
// re-attaches it as a trailing SET DEFAULT). Never the serial shorthand: that would
|
|
263
|
+
// mint a NEW sequence and silently detach the column from the one it declared.
|
|
264
|
+
const heldSequenceDraw = spec.defaultKind === 'sql' && nextvalSequence(def) != null;
|
|
265
|
+
if (def && !heldSequenceDraw) s += ` DEFAULT ${def}`;
|
|
255
266
|
if (spec.isNotNull) s += ' NOT NULL';
|
|
256
267
|
return s;
|
|
257
268
|
}
|
|
@@ -485,6 +496,26 @@ export function compileEnums(models: ModelDescriptor[]): EnumType[] {
|
|
|
485
496
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
486
497
|
}
|
|
487
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Standalone-sequence descriptors → the snapshot shape the diff and fingerprint consume.
|
|
501
|
+
* PostgreSQL's defaults (start/increment of 1) are omitted, so a declaration that omits
|
|
502
|
+
* them reaches the same canonical form a live default-configured sequence introspects to.
|
|
503
|
+
*/
|
|
504
|
+
export function compileSequences(sequences: readonly SequenceDescriptor[]): SequenceSchema[] {
|
|
505
|
+
return sequences
|
|
506
|
+
.map((s) => ({
|
|
507
|
+
name: s.name,
|
|
508
|
+
as: s.as,
|
|
509
|
+
...(s.start != null && s.start !== 1 ? { start: s.start } : {}),
|
|
510
|
+
...(s.increment != null && s.increment !== 1 ? { increment: s.increment } : {}),
|
|
511
|
+
}))
|
|
512
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The DDL renderer lives in schema-diff (emitOne needs it; this file already imports
|
|
516
|
+
// schema-diff, so defining it there avoids a cycle) — re-exported here beside its compiler.
|
|
517
|
+
export { sequenceCreateSql } from './schema-diff.js';
|
|
518
|
+
|
|
488
519
|
/**
|
|
489
520
|
* Foreign-key constraints for a model — its `.references()` fields and its table-level
|
|
490
521
|
* `foreignKey(...)` constraints, via `modelForeignKeys` — as `ALTER TABLE` statements. These
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
ColumnSchema,
|
|
31
31
|
EnumType,
|
|
32
32
|
IndexSchema,
|
|
33
|
+
SequenceSchema,
|
|
33
34
|
} from './schema-introspect.js';
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
@@ -79,10 +80,14 @@ export type SchemaChange =
|
|
|
79
80
|
| { kind: 'dropType'; name: string }
|
|
80
81
|
/** An enum value the DB has but the Model dropped/renamed — Postgres can't drop one in place (a notice). */
|
|
81
82
|
| { kind: 'enumValuesChanged'; name: string; from: string[]; to: string[] }
|
|
82
|
-
/** A standalone index to create → `CREATE [UNIQUE] INDEX … [WHERE …]`. */
|
|
83
|
-
| { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string }
|
|
83
|
+
/** A standalone index to create → `CREATE [UNIQUE] INDEX … [USING …] (…) [INCLUDE …] [WHERE …]`. */
|
|
84
|
+
| { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[] }
|
|
84
85
|
/** A standalone index present in the DB but no longer declared → `DROP INDEX` (a removal — gated). */
|
|
85
|
-
| { kind: 'dropIndex'; table: string; name: string }
|
|
86
|
+
| { kind: 'dropIndex'; table: string; name: string }
|
|
87
|
+
/** A declared standalone sequence missing live → `CREATE SEQUENCE`, before any table. */
|
|
88
|
+
| { kind: 'createSequence'; sequence: SequenceSchema }
|
|
89
|
+
/** A declared sequence whose live properties differ — a NOTICE, never a silent ALTER (it holds state). */
|
|
90
|
+
| { kind: 'sequenceDrift'; name: string; declared: SequenceSchema; live: SequenceSchema };
|
|
86
91
|
|
|
87
92
|
/**
|
|
88
93
|
* Rename intent, keyed like the snapshot's tables (`public.posts`) → { newColumn: oldColumn }.
|
|
@@ -131,9 +136,48 @@ export function normalizeDefault(expr: string | null): string | null {
|
|
|
131
136
|
// `nextval('public.x_seq')` on another. public is always on the search_path — strip it.
|
|
132
137
|
s = s.replace(/^nextval\('public\.([^']+)'/, "nextval('$1'");
|
|
133
138
|
|
|
139
|
+
// The deparser also casts the sequence literal (`nextval('x_seq'::regclass)`); an
|
|
140
|
+
// authored `defaultSql` may spell it bare. One canonical (castless) form for both —
|
|
141
|
+
// a normalization change, so FINGERPRINT_VERSION was bumped with it.
|
|
142
|
+
s = s.replace(/^nextval\('([^']+)'::regclass\)$/, "nextval('$1')");
|
|
143
|
+
|
|
134
144
|
return s;
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The sequence a `nextval` default draws from (schema qualification and the `::regclass`
|
|
149
|
+
* cast stripped), or null when the expression isn't a plain nextval. Drives two decisions:
|
|
150
|
+
* the `serial` shorthand fires ONLY for a column's own conventional `<table>_<col>_seq`
|
|
151
|
+
* (anything else would mint a NEW sequence), and a foreign-sequence default is held out
|
|
152
|
+
* of CREATE/ADD and re-attached as a trailing SET DEFAULT (its sequence may live on a
|
|
153
|
+
* table created later).
|
|
154
|
+
*/
|
|
155
|
+
export function nextvalSequence(expr: string | null): string | null {
|
|
156
|
+
if (expr == null) return null;
|
|
157
|
+
const m = expr.trim().match(/^nextval\('(?:[^'.]+\.)?([^'.]+)'(?:::regclass)?\)$/);
|
|
158
|
+
return m ? m[1] : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `public.contracts` / `contracts` → `contracts` (the bare table name for sequence conventions). */
|
|
162
|
+
function bareTable(table: string): string {
|
|
163
|
+
return table.replace(/^[^.]+\./, '');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `CREATE SEQUENCE` DDL for a standalone sequence — emitted BEFORE any table, so a
|
|
167
|
+
* column's `defaultSql(nextval(…))` can draw on it from the first CREATE. */
|
|
168
|
+
export function sequenceCreateSql(seq: SequenceSchema): string {
|
|
169
|
+
let s = `CREATE SEQUENCE ${seq.name} AS ${seq.as}`;
|
|
170
|
+
if (seq.start != null) s += ` START WITH ${seq.start}`;
|
|
171
|
+
if (seq.increment != null) s += ` INCREMENT BY ${seq.increment}`;
|
|
172
|
+
return `${s};`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True when a column's default draws a sequence that is NOT its own conventional one. */
|
|
176
|
+
function isForeignSequenceDefault(table: string, col: ColumnSchema): boolean {
|
|
177
|
+
const seq = nextvalSequence(col.default);
|
|
178
|
+
return seq != null && seq !== `${bareTable(table)}_${col.name}_seq`;
|
|
179
|
+
}
|
|
180
|
+
|
|
137
181
|
// ---------------------------------------------------------------------------
|
|
138
182
|
// Type-change classification — does the conversion need a USING cast and a warning?
|
|
139
183
|
// ---------------------------------------------------------------------------
|
|
@@ -318,29 +362,76 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
318
362
|
|
|
319
363
|
const e = diffEnums(desired.enums ?? [], current.enums ?? []);
|
|
320
364
|
|
|
365
|
+
// Standalone sequences — existence-diffed, DECLARED-SCOPED (a live sequence the models
|
|
366
|
+
// never declared is invisible: it holds state, and undeclared ≠ ours to drop). A declared
|
|
367
|
+
// one missing live is a create, before any table (a defaultSql may draw on it from the
|
|
368
|
+
// first CREATE); live property drift is a NOTICE — never a silent ALTER on a counter.
|
|
369
|
+
const sequenceCreates: SchemaChange[] = [];
|
|
370
|
+
const sequenceNotices: SchemaChange[] = [];
|
|
371
|
+
const currentSeqs = new Map((current.sequences ?? []).map((s) => [s.name, s]));
|
|
372
|
+
for (const declared of desired.sequences ?? []) {
|
|
373
|
+
const liveSeq = currentSeqs.get(declared.name);
|
|
374
|
+
if (!liveSeq) {
|
|
375
|
+
sequenceCreates.push({ kind: 'createSequence', sequence: declared });
|
|
376
|
+
} else if (liveSeq.as !== declared.as || (liveSeq.increment ?? 1) !== (declared.increment ?? 1)) {
|
|
377
|
+
sequenceNotices.push({ kind: 'sequenceDrift', name: declared.name, declared, live: liveSeq });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Foreign-sequence defaults are held out of CREATE/ADD by the emitters (the sequence
|
|
382
|
+
// may live on a table created later in the same batch) and re-attached here as
|
|
383
|
+
// SET DEFAULT changes ordered after every create and add.
|
|
384
|
+
const trailingDefaults: SchemaChange[] = [];
|
|
385
|
+
for (const c of creates) {
|
|
386
|
+
if (c.kind !== 'createTable') continue;
|
|
387
|
+
for (const col of c.schema.columns) {
|
|
388
|
+
if (isForeignSequenceDefault(c.table, col)) {
|
|
389
|
+
trailingDefaults.push({ kind: 'setDefault', table: c.table, column: col.name, expr: col.default! });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const c of addColumns) {
|
|
394
|
+
if (c.kind !== 'addColumn') continue;
|
|
395
|
+
if (isForeignSequenceDefault(c.table, c.column)) {
|
|
396
|
+
trailingDefaults.push({ kind: 'setDefault', table: c.table, column: c.column.name, expr: c.column.default! });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
321
400
|
// Enum types come first (a table or column may reference one); value adds before the
|
|
322
401
|
// columns that use them. Table renames run before creates (a rename frees a name a
|
|
323
402
|
// create may reuse) and before every per-table change (which all target the new name).
|
|
324
403
|
// Column renames run before adds and alters for the same reason. Enum drops sit with
|
|
325
404
|
// the other removals; notices are comments — emitted last, after the DDL.
|
|
326
405
|
return [
|
|
327
|
-
...e.creates, ...e.adds,
|
|
406
|
+
...e.creates, ...e.adds, ...sequenceCreates,
|
|
328
407
|
...renameTables,
|
|
329
|
-
...creates, ...renameColumns, ...addColumns, ...alters,
|
|
408
|
+
...creates, ...renameColumns, ...addColumns, ...alters, ...trailingDefaults,
|
|
330
409
|
...dropConstraints, ...addConstraints, ...createIndexes,
|
|
331
410
|
...dropColumns, ...dropIndexes, ...dropTables,
|
|
332
|
-
...e.drops, ...notices, ...e.notices,
|
|
411
|
+
...e.drops, ...notices, ...e.notices, ...sequenceNotices,
|
|
333
412
|
];
|
|
334
413
|
}
|
|
335
414
|
|
|
336
415
|
/** A `createIndex` change from an index spec (the new-table path and the per-table diff share it). */
|
|
337
416
|
function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
|
|
338
|
-
return {
|
|
417
|
+
return {
|
|
418
|
+
kind: 'createIndex', table, name: ix.name, columns: ix.columns, unique: ix.unique,
|
|
419
|
+
...(ix.where ? { where: ix.where } : {}),
|
|
420
|
+
...(ix.using ? { using: ix.using } : {}),
|
|
421
|
+
...(ix.include?.length ? { include: ix.include } : {}),
|
|
422
|
+
};
|
|
339
423
|
}
|
|
340
424
|
|
|
341
|
-
/**
|
|
425
|
+
/**
|
|
426
|
+
* An index's content identity — key entries (expressions/direction/opclass are canonical
|
|
427
|
+
* text, normalized like checks), uniqueness, predicate, access method, and INCLUDE set:
|
|
428
|
+
* name-independent, and no longer blind to anything `CREATE INDEX` can say (Brick E —
|
|
429
|
+
* the consumer's 31 phantom drop/creates). Plain btree indexes key exactly as before
|
|
430
|
+
* plus a constant tail — both sides compute the same key, so nothing churns.
|
|
431
|
+
*/
|
|
342
432
|
function indexKey(ix: IndexSchema): string {
|
|
343
|
-
|
|
433
|
+
const entries = ix.columns.map((c) => normalizeCheck(c)).join(',');
|
|
434
|
+
return `${ix.unique ? 'u' : ''}|${entries}|${normalizeCheck(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
|
|
344
435
|
}
|
|
345
436
|
|
|
346
437
|
/**
|
|
@@ -555,25 +646,31 @@ const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
|
555
646
|
* column), or null. CREATE/ADD COLUMN must use the shorthand — it creates the backing
|
|
556
647
|
* sequence, which a bare `DEFAULT nextval(...)` would reference before it exists.
|
|
557
648
|
*/
|
|
558
|
-
function serialSpelling(col: ColumnSchema): string | null {
|
|
559
|
-
|
|
649
|
+
function serialSpelling(col: ColumnSchema, table: string): string | null {
|
|
650
|
+
// ONLY the column's own conventional sequence is a serial declaration — the shorthand
|
|
651
|
+
// creates a sequence, so spelling it for a foreign `nextval` would mint a duplicate
|
|
652
|
+
// and silently detach the column from the sequence it declared.
|
|
653
|
+
const seq = nextvalSequence(col.default);
|
|
654
|
+
if (seq == null || seq !== `${bareTable(table)}_${col.name}_seq`) return null;
|
|
560
655
|
if (col.type === 'integer') return 'serial';
|
|
561
656
|
if (col.type === 'bigint') return 'bigserial';
|
|
562
657
|
return null;
|
|
563
658
|
}
|
|
564
659
|
|
|
565
660
|
/** A column definition body (no leading indent): `"name" type [DEFAULT expr] [NOT NULL]`. */
|
|
566
|
-
function columnSql(col: ColumnSchema): string {
|
|
567
|
-
const serial = serialSpelling(col);
|
|
661
|
+
function columnSql(col: ColumnSchema, table: string): string {
|
|
662
|
+
const serial = serialSpelling(col, table);
|
|
568
663
|
let s = `${quote(col.name)} ${serial ?? col.type}`;
|
|
569
|
-
|
|
664
|
+
// A foreign-sequence default is held out — diffSchema appends the trailing SET DEFAULT
|
|
665
|
+
// (after every create), so the CREATE never references a sequence that doesn't exist yet.
|
|
666
|
+
if (!serial && col.default != null && !isForeignSequenceDefault(table, col)) s += ` DEFAULT ${col.default}`;
|
|
570
667
|
if (col.notNull) s += ' NOT NULL';
|
|
571
668
|
return s;
|
|
572
669
|
}
|
|
573
670
|
|
|
574
671
|
/** `CREATE TABLE` from the IR: columns + PK/unique/check inline; FKs are emitted separately. */
|
|
575
672
|
function createTableSql(t: TableSchema): string {
|
|
576
|
-
const lines = t.columns.map((c) => `\t${columnSql(c)}`);
|
|
673
|
+
const lines = t.columns.map((c) => `\t${columnSql(c, t.table)}`);
|
|
577
674
|
if (t.primaryKey.length) lines.push(`\tPRIMARY KEY (${colList(t.primaryKey)})`);
|
|
578
675
|
for (const u of t.uniques) lines.push(`\tCONSTRAINT ${quote(u.name)} UNIQUE (${colList(u.columns)})`);
|
|
579
676
|
for (const ck of t.checks) lines.push(`\tCONSTRAINT ${quote(ck.name)} CHECK ${wrapOnce(ck.expr)}`);
|
|
@@ -613,7 +710,7 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
613
710
|
case 'dropTable':
|
|
614
711
|
return `DROP TABLE ${change.table};`;
|
|
615
712
|
case 'addColumn': {
|
|
616
|
-
const ddl = `ALTER TABLE ${change.table} ADD COLUMN ${columnSql(change.column)};`;
|
|
713
|
+
const ddl = `ALTER TABLE ${change.table} ADD COLUMN ${columnSql(change.column, change.table)};`;
|
|
617
714
|
// Adding a NOT NULL column with no default fails on a table that already has rows.
|
|
618
715
|
if (change.column.notNull && change.column.default == null) {
|
|
619
716
|
return `-- WARNING: ADD COLUMN ${quote(change.column.name)} NOT NULL on ${change.table} has no default and FAILS if the table has rows; add a default or backfill first.\n${ddl}`;
|
|
@@ -674,8 +771,13 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
674
771
|
return `DROP TYPE ${quote(change.name)};`;
|
|
675
772
|
case 'createIndex': {
|
|
676
773
|
const unique = change.unique ? 'UNIQUE ' : '';
|
|
774
|
+
const using = change.using ? ` USING ${change.using}` : '';
|
|
775
|
+
// A plain identifier entry quotes as before; a canonical-text entry (expression,
|
|
776
|
+
// DESC, opclass) passes verbatim — quoting the whole thing would mangle it.
|
|
777
|
+
const entry = (c: string): string => (/^[a-z_][a-z0-9_$]*$/i.test(c) ? quote(c) : c);
|
|
778
|
+
const include = change.include?.length ? ` INCLUDE (${colList(change.include)})` : '';
|
|
677
779
|
const where = change.where ? ` WHERE ${change.where}` : '';
|
|
678
|
-
return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table} (${
|
|
780
|
+
return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table}${using} (${change.columns.map(entry).join(', ')})${include}${where};`;
|
|
679
781
|
}
|
|
680
782
|
case 'dropIndex': {
|
|
681
783
|
const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
|
|
@@ -683,6 +785,10 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
683
785
|
}
|
|
684
786
|
case 'enumValuesChanged':
|
|
685
787
|
return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
|
|
788
|
+
case 'createSequence':
|
|
789
|
+
return sequenceCreateSql(change.sequence);
|
|
790
|
+
case 'sequenceDrift':
|
|
791
|
+
return `-- NOTICE: sequence ${change.name} differs from its declaration (declared AS ${change.declared.as}${change.declared.increment != null ? ` INCREMENT ${change.declared.increment}` : ''}; live AS ${change.live.as}${change.live.increment != null ? ` INCREMENT ${change.live.increment}` : ''}). A sequence holds a counter — reshape it by hand (ALTER SEQUENCE) after deciding what the counter should be.`;
|
|
686
792
|
}
|
|
687
793
|
}
|
|
688
794
|
|
|
@@ -39,23 +39,30 @@
|
|
|
39
39
|
* fingerprints. The version prefix plus the re-diff path keep that honest.
|
|
40
40
|
*
|
|
41
41
|
* Coverage is partial and SAYS SO: `UNFINGERPRINTED_SQL` enumerates what the
|
|
42
|
-
* base fingerprint does not see (
|
|
43
|
-
* composite types, foreign/partitioned
|
|
44
|
-
* coverage reads as a report, never as
|
|
42
|
+
* base fingerprint does not see (triggers — drift-checked separately by the
|
|
43
|
+
* reconciler's provenance — domains, composite types, foreign/partitioned
|
|
44
|
+
* tables, extensions) so partial coverage reads as a report, never as
|
|
45
|
+
* "covered everything". Standalone sequences left this list in v3: covered.
|
|
45
46
|
*/
|
|
46
47
|
|
|
47
48
|
import { createHash } from 'node:crypto';
|
|
48
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
49
|
+
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
49
50
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
|
|
50
51
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
51
|
-
import { compileTableSchema, compileEnums } from './schema-compile.js';
|
|
52
|
+
import { compileTableSchema, compileEnums, compileSequences } from './schema-compile.js';
|
|
52
53
|
import { compileTableContract } from './authz-compile.js';
|
|
53
54
|
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
54
55
|
|
|
55
56
|
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
56
57
|
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
57
58
|
// deparsed form agree — the identity `fingerprintModels === fingerprintLive` holds.
|
|
58
|
-
|
|
59
|
+
// v3: nextval defaults canonicalize to the castless form (`nextval('x_seq')`), so an
|
|
60
|
+
// authored `defaultSql` and the deparser's `nextval('x_seq'::regclass)` hash equal
|
|
61
|
+
// (canonical forms changed for every serial column's default → the bump). ALSO in v3:
|
|
62
|
+
// STANDALONE SEQUENCES enter the canonical form (declared via defineSequence,
|
|
63
|
+
// introspected from pg_sequence minus serial-owned) — a coverage expansion; the
|
|
64
|
+
// `sequences` key appears only when any exist, so sequence-free states hash unchanged.
|
|
65
|
+
export const FINGERPRINT_VERSION = 3;
|
|
59
66
|
|
|
60
67
|
// ---------------------------------------------------------------------------
|
|
61
68
|
// Canonical form.
|
|
@@ -149,12 +156,23 @@ export interface CanonicalState {
|
|
|
149
156
|
tables: Array<Record<string, unknown>>;
|
|
150
157
|
enums: Array<{ name: string; values: string[] }>;
|
|
151
158
|
authz: Array<Record<string, unknown>>;
|
|
159
|
+
/** Standalone sequences — present ONLY when any exist, so a sequence-free state's
|
|
160
|
+
* canonical form (and hash) is byte-identical to the pre-coverage form. */
|
|
161
|
+
sequences?: Array<Record<string, unknown>>;
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
export function canonicalizeState(
|
|
155
165
|
snapshot: SchemaSnapshot,
|
|
156
166
|
authzTables: TableContract[],
|
|
157
167
|
): CanonicalState {
|
|
168
|
+
const sequences = byKey(
|
|
169
|
+
(snapshot.sequences ?? []).map((s) => ({
|
|
170
|
+
name: s.name, as: s.as,
|
|
171
|
+
...(s.start != null ? { start: s.start } : {}),
|
|
172
|
+
...(s.increment != null ? { increment: s.increment } : {}),
|
|
173
|
+
})),
|
|
174
|
+
(s) => String(s.name),
|
|
175
|
+
);
|
|
158
176
|
return {
|
|
159
177
|
v: FINGERPRINT_VERSION,
|
|
160
178
|
tables: byKey(snapshot.tables.map(canonicalTable), (t) => String(t.table)),
|
|
@@ -163,6 +181,7 @@ export function canonicalizeState(
|
|
|
163
181
|
(e) => e.name,
|
|
164
182
|
),
|
|
165
183
|
authz: byKey(authzTables.map(canonicalAuthzTable), (t) => String(t.table)),
|
|
184
|
+
...(sequences.length ? { sequences } : {}),
|
|
166
185
|
};
|
|
167
186
|
}
|
|
168
187
|
|
|
@@ -184,11 +203,12 @@ export function fingerprintState(
|
|
|
184
203
|
/** The DECLARED state: compile the models to the same canonical shape and hash it. */
|
|
185
204
|
export function fingerprintModels(
|
|
186
205
|
models: ModelDescriptor[],
|
|
187
|
-
opts: { schema?: string } = {},
|
|
206
|
+
opts: { schema?: string; sequences?: SequenceDescriptor[] } = {},
|
|
188
207
|
): Fingerprint {
|
|
189
208
|
const snapshot: SchemaSnapshot = {
|
|
190
209
|
tables: models.map((m) => compileTableSchema(m, opts)),
|
|
191
210
|
enums: compileEnums(models),
|
|
211
|
+
...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
|
|
192
212
|
};
|
|
193
213
|
const authzTables = models.map((m) => compileTableContract(m, opts));
|
|
194
214
|
return fingerprintState(snapshot, authzTables);
|
|
@@ -210,17 +230,7 @@ export function fingerprintLive(snapshot: SchemaSnapshot, contract: AuthzContrac
|
|
|
210
230
|
* types, foreign tables, partitioned tables, and installed extensions.
|
|
211
231
|
*/
|
|
212
232
|
export const UNFINGERPRINTED_SQL = `
|
|
213
|
-
SELECT '
|
|
214
|
-
FROM pg_class c
|
|
215
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
216
|
-
WHERE c.relkind = 'S'
|
|
217
|
-
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
218
|
-
AND NOT EXISTS (
|
|
219
|
-
SELECT 1 FROM pg_depend d
|
|
220
|
-
WHERE d.objid = c.oid AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
|
|
221
|
-
)
|
|
222
|
-
UNION ALL
|
|
223
|
-
SELECT 'trigger', n.nspname || '.' || c.relname || '.' || t.tgname
|
|
233
|
+
SELECT 'trigger' AS kind, n.nspname || '.' || c.relname || '.' || t.tgname AS identity
|
|
224
234
|
FROM pg_trigger t
|
|
225
235
|
JOIN pg_class c ON c.oid = t.tgrelid
|
|
226
236
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|