@everystack/cli 0.3.31 → 0.4.1

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 (43) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
  4. package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
  5. package/src/cli/authz-compile.ts +5 -2
  6. package/src/cli/aws.ts +12 -3
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-authz.ts +3 -17
  9. package/src/cli/commands/db-branch.ts +28 -4
  10. package/src/cli/commands/db-check.ts +84 -33
  11. package/src/cli/commands/db-fingerprint.ts +7 -2
  12. package/src/cli/commands/db-generate.ts +102 -34
  13. package/src/cli/commands/db-pull.ts +82 -6
  14. package/src/cli/commands/db-reconcile.ts +132 -41
  15. package/src/cli/commands/db-sync.ts +54 -19
  16. package/src/cli/commands/db.ts +7 -6
  17. package/src/cli/commands/update.ts +2 -1
  18. package/src/cli/db-build.ts +9 -3
  19. package/src/cli/db-source.ts +5 -1
  20. package/src/cli/declared-derived.ts +173 -0
  21. package/src/cli/derived-apply.ts +40 -6
  22. package/src/cli/derived-compile.ts +377 -0
  23. package/src/cli/derived-grants.ts +96 -0
  24. package/src/cli/derived-introspect.ts +258 -21
  25. package/src/cli/derived-lint.ts +261 -0
  26. package/src/cli/derived-plan.ts +176 -10
  27. package/src/cli/derived-render.ts +366 -0
  28. package/src/cli/derived-source.ts +53 -125
  29. package/src/cli/edge-plan.ts +4 -1
  30. package/src/cli/git-descent.ts +15 -2
  31. package/src/cli/index.ts +6 -6
  32. package/src/cli/migration-compile.ts +38 -7
  33. package/src/cli/migration-generate.ts +43 -4
  34. package/src/cli/model-render.ts +125 -16
  35. package/src/cli/models-path.ts +1 -1
  36. package/src/cli/ops-advice.ts +66 -0
  37. package/src/cli/pipeline-path.ts +1 -1
  38. package/src/cli/schema-compile.ts +41 -10
  39. package/src/cli/schema-diff.ts +123 -17
  40. package/src/cli/schema-fingerprint.ts +28 -18
  41. package/src/cli/schema-introspect.ts +175 -8
  42. package/src/cli/schema-source.ts +75 -6
  43. package/src/cli/state-apply.ts +52 -1
package/README.md CHANGED
@@ -216,7 +216,7 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
216
216
  # Model-driven schema (@everystack/model)
217
217
  everystack db:generate # diff Models vs the live DB → next migration
218
218
  everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
219
- everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
219
+ everystack db:reconcile # deploy the derived layer (functions/views/matviews/triggers) from the declared descriptors in db/models — no migrations
220
220
  everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
221
  everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
222
222
  everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.31",
3
+ "version": "0.4.1",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -82,7 +82,7 @@
82
82
  "structured-headers": "1.0.1",
83
83
  "tsx": "4.21.0",
84
84
  "typescript": "5.9.3",
85
- "@everystack/model": "0.3.6"
85
+ "@everystack/model": "0.4.1"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@everystack/server": ">=0.1.0",
@@ -0,0 +1,59 @@
1
+ import { defineModel, field, can, index, defineModule, sql, arg, defineFunction, defineMaterializedView, defineSequence, defineView } from '@everystack/model';
2
+
3
+ export const PdrPost = defineModel('pdr_posts', {
4
+ abilities: [can('read'), can('manage', { role: 'admin' })],
5
+ fields: {
6
+ id: field.integer().primaryKey(),
7
+ title: field.text().notNull(),
8
+ tags: field.text().array().notNull().default("{}"),
9
+ },
10
+ });
11
+
12
+ export const pdrTicketSeq = defineSequence('pdr_ticket_seq', { start: 500 });
13
+
14
+ export const pdrsPdrArea = defineFunction('pdrs.pdr_area', {
15
+ args: [arg('w', 'integer'), arg('h', 'integer')],
16
+ returns: 'integer',
17
+ language: 'sql',
18
+ volatility: 'immutable',
19
+ abilities: [can('execute', { role: 'authenticated' })],
20
+ body: sql`SELECT w * h`,
21
+ });
22
+
23
+ export const pdrBoard = defineMaterializedView('pdr_board', {
24
+ abilities: [can('read')],
25
+ dependsOn: [PdrPost],
26
+ indexes: [index('tags').using('gin')],
27
+ as: sql`SELECT tags,
28
+ count(*) AS n
29
+ FROM pdr_posts
30
+ GROUP BY tags`,
31
+ });
32
+
33
+ export const pdrScore = defineFunction('pdr_score', {
34
+ args: [arg('points', 'integer', { default: sql`1` })],
35
+ returns: 'integer',
36
+ language: 'sql',
37
+ volatility: 'stable',
38
+ abilities: [can('execute', { role: 'authenticated' })],
39
+ body: sql`SELECT points * 2`,
40
+ });
41
+
42
+ export const pdrTitles = defineView('pdr_titles', {
43
+ securityInvoker: false,
44
+ abilities: [can('read')],
45
+ dependsOn: [PdrPost],
46
+ as: sql`SELECT id,
47
+ title
48
+ FROM pdr_posts`,
49
+ });
50
+
51
+ export const models = [PdrPost];
52
+
53
+ export const sequences = [pdrTicketSeq];
54
+
55
+ export const derived = [pdrsPdrArea, pdrBoard, pdrScore, pdrTitles];
56
+
57
+ export const appModule = defineModule({ models, sequences, derived });
58
+
59
+ export const modules = [appModule];
@@ -0,0 +1,121 @@
1
+ import { defineModel, field, unique, check, index, foreignKey, defineModule, sql } from '@everystack/model';
2
+ import { z } from 'zod';
3
+
4
+ export const RtAccount = defineModel('rt_accounts', {
5
+ // Declare the read model — db:check fails this model until its authz is authored:
6
+ // abilities: [can('read')], // public data
7
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
8
+ // private: true, // not part of the data API
9
+ fields: {
10
+ id: field.uuid().primaryKey().defaultRandom(),
11
+ email: field.text().notNull().unique(),
12
+ displayName: field.text().notNull(),
13
+ age: field.integer(),
14
+ balance: field.bigint().notNull().default(0),
15
+ creditLimit: field.numeric(12, 2),
16
+ score: field.numeric(8),
17
+ slug: field.varchar(255).notNull(),
18
+ region: field.varchar(),
19
+ bio: field.text().validate(z.string().max(160)),
20
+ active: field.boolean().notNull().default(true),
21
+ role: field.text().notNull().default("member"),
22
+ status: field.enum('account_status', ['active', 'suspended', 'closed']).notNull().default("active"),
23
+ metadata: field.jsonb(),
24
+ bornOn: field.date(),
25
+ createdAt: field.timestamptz().notNull().defaultNow(),
26
+ updatedAt: field.timestamp(),
27
+ },
28
+ });
29
+
30
+ export const RtMetricArchive = defineModel('rt_metric_archives', {
31
+ // Declare the read model — db:check fails this model until its authz is authored:
32
+ // abilities: [can('read')], // public data
33
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
34
+ // private: true, // not part of the data API
35
+ fields: {
36
+ id: field.integer().primaryKey().defaultSql(sql`nextval('rt_metrics_id_seq'::regclass)`),
37
+ note: field.text(),
38
+ },
39
+ });
40
+
41
+ export const RtMetric = defineModel('rt_metrics', {
42
+ // Declare the read model — db:check fails this model until its authz is authored:
43
+ // abilities: [can('read')], // public data
44
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
45
+ // private: true, // not part of the data API
46
+ fields: {
47
+ id: field.serial().primaryKey(),
48
+ noteId: field.uuid().notNull().references(() => RtNote),
49
+ coverRate: field.real(),
50
+ meanMiss: field.doublePrecision().notNull(),
51
+ counter: field.bigserial(),
52
+ yardline_100: field.integer(),
53
+ top_3Best: field.integer(),
54
+ tags: field.text().array(),
55
+ },
56
+ });
57
+
58
+ export const RtNoteTag = defineModel('rt_note_tags', {
59
+ // Declare the read model — db:check fails this model until its authz is authored:
60
+ // abilities: [can('read')], // public data
61
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
62
+ // private: true, // not part of the data API
63
+ fields: {
64
+ noteId: field.uuid().primaryKey().references(() => RtNote),
65
+ label: field.text().primaryKey(),
66
+ },
67
+ });
68
+
69
+ export const RtNote = defineModel('rt_notes', {
70
+ // Declare the read model — db:check fails this model until its authz is authored:
71
+ // abilities: [can('read')], // public data
72
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
73
+ // private: true, // not part of the data API
74
+ fields: {
75
+ id: field.uuid().primaryKey().defaultRandom(),
76
+ accountId: field.uuid().notNull().references(() => RtAccount, { onDelete: 'cascade' }),
77
+ parentId: field.uuid().references(() => RtNote, { onDelete: 'set null', onUpdate: 'cascade' }),
78
+ title: field.text().notNull(),
79
+ body: field.text(),
80
+ pinned: field.boolean().notNull().default(false),
81
+ createdAt: field.timestamptz().notNull().defaultNow(),
82
+ },
83
+ constraints: [
84
+ unique('accountId', 'title'),
85
+ check(sql`char_length(title) > 0`),
86
+ index('createdAt').where(sql`pinned = true`),
87
+ index('parentId'),
88
+ ],
89
+ });
90
+
91
+ export const RtTagLink = defineModel('rt_tag_links', {
92
+ // Declare the read model — db:check fails this model until its authz is authored:
93
+ // abilities: [can('read')], // public data
94
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
95
+ // private: true, // not part of the data API
96
+ fields: {
97
+ id: field.uuid().primaryKey().defaultRandom(),
98
+ noteId: field.uuid().notNull(),
99
+ label: field.text().notNull(),
100
+ },
101
+ constraints: [
102
+ foreignKey(['noteId', 'label'], () => RtNoteTag, ['noteId', 'label'], { onDelete: 'cascade' }),
103
+ ],
104
+ });
105
+
106
+ export const RtTicket = defineModel('rt_tickets', {
107
+ // Declare the read model — db:check fails this model until its authz is authored:
108
+ // abilities: [can('read')], // public data
109
+ // abilities: [can('read', { owner: '<column>' })], // rows owned by a user
110
+ // private: true, // not part of the data API
111
+ fields: {
112
+ id: field.uuid().primaryKey().defaultRandom(),
113
+ ticketNumber: field.bigint().notNull().defaultSql(sql`nextval('rt_ticket_counter'::regclass)`),
114
+ },
115
+ });
116
+
117
+ export const models = [RtAccount, RtMetricArchive, RtMetric, RtNoteTag, RtNote, RtTagLink, RtTicket];
118
+
119
+ export const appModule = defineModule({ models });
120
+
121
+ export const modules = [appModule];
@@ -308,8 +308,10 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
308
308
  /** The four DML privileges, sorted — the expansion of a `manage` ability. */
309
309
  const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
310
310
 
311
- /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
312
- const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
311
+ /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD.
312
+ * `execute` is a FUNCTION ability — defineModel rejects it on tables, so it never
313
+ * reaches the table grant compiler (the throw below keeps that contract loud). */
314
+ const VERB: Record<Exclude<Ability['action'], 'manage' | 'execute'>, string> = {
313
315
  read: 'SELECT',
314
316
  create: 'INSERT',
315
317
  update: 'UPDATE',
@@ -318,6 +320,7 @@ const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
318
320
 
319
321
  /** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
320
322
  function verbsFor(action: Ability['action']): string[] {
323
+ if (action === 'execute') throw new Error(`can('execute') is a function ability — it has no table privilege (defineModel rejects it; this is unreachable).`);
321
324
  return action === 'manage' ? [...CRUD] : [VERB[action]];
322
325
  }
323
326
 
package/src/cli/aws.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { isOpsHandlerCrash, extractCrashDiagnosis, formatOpsCrashReport } from './ops-diagnostics.js';
9
+ import { tagInvokeTransport } from './ops-advice.js';
9
10
 
10
11
  let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
11
12
  let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
@@ -262,6 +263,10 @@ export async function invokeAction(
262
263
  const invoke = (fnName: string) =>
263
264
  client.send(new InvokeCommand({ FunctionName: fnName, Payload: encoded }));
264
265
 
266
+ // A throw from the SDK means the Lambda NEVER RAN (credentials, permissions,
267
+ // network, missing function) — tagged as transport so the shells' IAM advice
268
+ // prints for THIS class only. A FunctionError below is the opposite: the
269
+ // handler ran and threw, and IAM advice would be a lie.
265
270
  let response;
266
271
  let invokedName = functionName;
267
272
  try {
@@ -272,7 +277,7 @@ export async function invokeAction(
272
277
  // then bust the stale cache so the next command discovers fresh.
273
278
  if (err && typeof err === 'object' && (err as { name?: string }).name === 'ResourceNotFoundException') {
274
279
  const live = await resolveFunctionName(region, functionName);
275
- if (live === functionName) throw err;
280
+ if (live === functionName) throw tagInvokeTransport(err);
276
281
  try {
277
282
  const { invalidateCacheByFunctionName } = await import('./discover.js');
278
283
  await invalidateCacheByFunctionName(functionName);
@@ -280,9 +285,13 @@ export async function invokeAction(
280
285
  // Cache invalidation is best-effort
281
286
  }
282
287
  invokedName = live;
283
- response = await invoke(live);
288
+ try {
289
+ response = await invoke(live);
290
+ } catch (err2: unknown) {
291
+ throw tagInvokeTransport(err2);
292
+ }
284
293
  } else {
285
- throw err;
294
+ throw tagInvokeTransport(err);
286
295
  }
287
296
  }
288
297
 
@@ -8,7 +8,7 @@
8
8
  * unlike `schema_log`, which stays a write-only memoir. The distrust that
9
9
  * killed the migration tape doesn't transfer: nothing is numbered, nothing
10
10
  * is watermarked, and identity is the NORMALIZED CONTENT HASH (the same
11
- * comment-insensitive, dollar-quote-aware lexing as db/sql) — renaming a
11
+ * comment-insensitive, dollar-quote-aware lexing as the reconciler) — renaming a
12
12
  * file or editing a comment is a no-op, never a re-run.
13
13
  *
14
14
  * One-shot jobs are IMMUTABLE. A file whose name already ran on this
@@ -15,8 +15,6 @@
15
15
  */
16
16
 
17
17
  import path from 'node:path';
18
- import { pathToFileURL } from 'node:url';
19
- import type { ModelDescriptor } from '@everystack/model';
20
18
  import {
21
19
  introspectContract,
22
20
  diffContracts,
@@ -47,6 +45,8 @@ import { resolveConfig, opsFunction } from '../config.js';
47
45
  import { resolveModelsPath } from '../models-path.js';
48
46
  import { invokeAction } from '../aws.js';
49
47
  import { step, success, fail, info, warn } from '../output.js';
48
+ import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
49
+ import { loadModels } from './db-generate.js';
50
50
 
51
51
  const DEFAULT_DIR = 'authz';
52
52
 
@@ -75,7 +75,7 @@ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise
75
75
  contract = await introspectStage(flags);
76
76
  } catch (err: any) {
77
77
  fail(err.message);
78
- info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
78
+ for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
79
79
  process.exit(1);
80
80
  }
81
81
 
@@ -207,20 +207,6 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
207
207
  process.exit(1);
208
208
  }
209
209
 
210
- /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
211
- async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
212
- const abs = path.resolve(modelsPath);
213
- let mod: any;
214
- try {
215
- mod = await import(pathToFileURL(abs).href);
216
- } catch (err: any) {
217
- throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
218
- }
219
- const models = mod.models ?? mod.default;
220
- if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
221
- return models;
222
- }
223
-
224
210
  /**
225
211
  * `db:authz:owner` — the owner-aware red-team. Where `db:authz:test` proves grants (can a ROLE
226
212
  * do a command), this proves ISOLATION: can one user read or mutate another user's row? It is
@@ -4,7 +4,7 @@
4
4
  * decision 14).
5
5
  *
6
6
  * db:template:refresh [--database-url <url>] [--models <barrel>]
7
- * [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed]
7
+ * [--seed "<cmd>"] [--no-seed]
8
8
  * db:branch # mint (or find) the current branch's DB
9
9
  * db:branch --list # every branch DB, mapped to its branch
10
10
  * db:branch --drop --confirm # drop the current branch's DB
@@ -39,7 +39,7 @@ import { currentGitRef } from '../state-apply.js';
39
39
  import { fingerprintModels } from '../schema-fingerprint.js';
40
40
  import { resolveDbSource, type DbSource } from '../db-source.js';
41
41
  import { resolveModelsPath } from '../models-path.js';
42
- import { readSqlDirIfPresent } from './db-sync.js';
42
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
43
43
  import { loadModels } from './db-generate.js';
44
44
  import { step, success, fail, info, warn } from '../output.js';
45
45
 
@@ -92,7 +92,30 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
92
92
  fail(err.message);
93
93
  process.exit(1);
94
94
  }
95
- const sources = await readSqlDirIfPresent(flags['sql-dir'] || 'db/sql');
95
+ // B7 tombstone db/sql is retired and no longer read.
96
+ try {
97
+ if (flags['sql-dir']) {
98
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
99
+ process.exit(1);
100
+ }
101
+ const retired = await retiredSqlDirAnywhere(flags.models);
102
+ if (retired) {
103
+ fail(retired);
104
+ process.exit(1);
105
+ }
106
+ } catch (err: any) {
107
+ fail(err.message);
108
+ process.exit(1);
109
+ }
110
+ // The declared derived layer + sequences — branch/template DBs carry the SAME stream
111
+ // every other verb deploys, or a minted branch is silently missing its compute.
112
+ let declaredDb: DeclaredDerived | null = null;
113
+ try {
114
+ declaredDb = await loadDeclaredDerived(flags.models);
115
+ } catch (err: any) {
116
+ fail(err.message);
117
+ process.exit(1);
118
+ }
96
119
 
97
120
  let seedCommand: string | null = null;
98
121
  if (flags['no-seed'] === 'true') {
@@ -109,7 +132,8 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
109
132
  step(`Rebuilding ${template} from the declared state (drop → create → sync both layers → seed)...`);
110
133
  try {
111
134
  const result = await executeTemplateRefresh(url, models, {
112
- sources,
135
+ declared: declaredDb?.objects,
136
+ sequences: declaredDb?.sequences,
113
137
  actor: process.env.USER ?? null,
114
138
  gitRef: currentGitRef(),
115
139
  seed: seedCommand === null ? null : (templateUrl) => {
@@ -3,7 +3,7 @@
3
3
  * invariant: the merged declared state must compose, and generated
4
4
  * artifacts must match regeneration).
5
5
  *
6
- * db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>]
6
+ * db:check [--models <barrel>] [--schema-out <file.ts>]
7
7
  * [--database-url <url>] [--json]
8
8
  *
9
9
  * Two rings, both from the checkout alone (no target database is ever
@@ -13,7 +13,8 @@
13
13
  * git can't see, like two branches declaring the same table, fails
14
14
  * here); the generated drizzle schema matches a byte-for-byte
15
15
  * regeneration (hand-edited artifacts and forgotten `db:generate` runs
16
- * fail loudly); the derived sources in db/sql parse.
16
+ * fail loudly); a leftover db/sql directory fails with the migration
17
+ * path (B7 — descriptors are the single home).
17
18
  *
18
19
  * EPHEMERAL COMPOSE — with a scratch PostgreSQL (`--database-url` /
19
20
  * DATABASE_URL), build the merged declared state FROM SCRATCH on an
@@ -28,24 +29,25 @@
28
29
  */
29
30
 
30
31
  import fs from 'node:fs/promises';
31
- import type { ModelDescriptor } from '@everystack/model';
32
+ import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
32
33
  import { compileDeclaredState } from '../declared-diff.js';
33
34
  import { compileMigration } from '../migration-compile.js';
34
35
  import { compileDrizzleSource } from '../schema-source.js';
35
- import { parseSqlSources, type SourceFile } from '../derived-source.js';
36
36
  import { currentGitRef } from '../state-apply.js';
37
37
  import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
38
38
  import { resolveModelsPath } from '../models-path.js';
39
- import { readSqlDirIfPresent } from './db-sync.js';
40
39
  import { loadModels } from './db-generate.js';
40
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
41
+ import type { SequenceDescriptor } from '@everystack/model';
42
+ import type { SourceObject } from '../derived-source.js';
41
43
  import { findReadAuthzGaps } from '../authz-lint.js';
44
+ import { findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings } from '../derived-lint.js';
42
45
  import { step, success, fail, info, warn } from '../output.js';
43
46
 
44
47
  // The role derivation moved to the shared builder (db-build) with brick
45
48
  // 5-remainder; re-exported here because db:check is where it grew up.
46
49
  export { rolesFromContract } from '../db-build.js';
47
50
 
48
- const DEFAULT_SQL_DIR = 'db/sql';
49
51
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
50
52
 
51
53
  // ---------------------------------------------------------------------------
@@ -53,7 +55,8 @@ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
53
55
  // ---------------------------------------------------------------------------
54
56
 
55
57
  export interface CheckFinding {
56
- level: 'ok' | 'note' | 'fail';
58
+ /** `warn` is visible-but-never-fatal — the matview snapshot gate's channel. */
59
+ level: 'ok' | 'note' | 'warn' | 'fail';
57
60
  area: 'models' | 'compose' | 'artifact' | 'compute' | 'authz';
58
61
  message: string;
59
62
  }
@@ -66,8 +69,15 @@ export interface StaticCheckInput {
66
69
  artifactPath: string;
67
70
  /** null = no generated schema artifact on disk. */
68
71
  artifactSource: string | null;
69
- /** null = no db/sql directory. */
70
- sources: SourceFile[] | null;
72
+ /** Set when a retired db/sql directory still carries .sql files — the tombstone
73
+ * message (with the migration path); a hard fail, never a shrug. */
74
+ sqlDirRetired?: string | null;
75
+ /** Standalone sequences the modules declare (state — in the from-scratch compose). */
76
+ sequences?: SequenceDescriptor[];
77
+ /** The declared derived descriptors (raw — the B2 gates read abilities, not SQL). */
78
+ derived?: DerivedDescriptor[] | null;
79
+ /** Set when the derived layer failed to COMPILE (e.g. reachability) — a hard fail. */
80
+ derivedError?: string;
71
81
  }
72
82
 
73
83
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -98,7 +108,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
98
108
  }
99
109
  try {
100
110
  compileDeclaredState(input.models);
101
- const statements = compileMigration(input.models);
111
+ const statements = compileMigration(input.models, { sequences: input.sequences });
102
112
  findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
103
113
  } catch (err: any) {
104
114
  findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
@@ -116,13 +126,43 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
116
126
  }
117
127
  }
118
128
 
129
+ // The derived layer failing to COMPILE (reachability, cycles, undeclared trigger
130
+ // functions) is a hard fail — CI cannot shrug at a compile error.
131
+ if (input.derivedError) {
132
+ findings.push({
133
+ level: 'fail',
134
+ area: 'compute',
135
+ message: `the declared derived layer does NOT compile: ${input.derivedError}`,
136
+ });
137
+ }
138
+
139
+ // The B2 gates over the declared derived layer: every relation makes its read decision
140
+ // (fail), every SECURITY DEFINER declares its callers (fail), and a matview over
141
+ // row-scoped sources is named for what it is — a snapshot with the refresher's eyes (warn).
142
+ if (input.derived?.length) {
143
+ const derivedGaps = [...findDerivedReadGaps(input.derived), ...findSecdefExecuteGaps(input.derived)];
144
+ for (const gap of derivedGaps) {
145
+ findings.push({ level: 'fail', area: 'authz', message: gap.message });
146
+ }
147
+ for (const warning of findMatviewSnapshotWarnings(input.derived)) {
148
+ findings.push({ level: 'warn', area: 'authz', message: warning.message });
149
+ }
150
+ if (derivedGaps.length === 0) {
151
+ findings.push({
152
+ level: 'ok',
153
+ area: 'authz',
154
+ message: `every derived relation declares its read surface and every SECURITY DEFINER its callers (${input.derived.length} descriptor(s))`,
155
+ });
156
+ }
157
+ }
158
+
119
159
  if (input.artifactSource === null) {
120
160
  findings.push({
121
161
  level: 'note',
122
162
  area: 'artifact',
123
163
  message: `no generated schema at ${input.artifactPath} — nothing to match (db:generate writes it)`,
124
164
  });
125
- } else if (input.artifactSource === compileDrizzleSource(input.models)) {
165
+ } else if (input.artifactSource === compileDrizzleSource(input.models, { derived: input.derived ?? undefined })) {
126
166
  findings.push({ level: 'ok', area: 'artifact', message: `${input.artifactPath} matches regeneration byte-for-byte` });
127
167
  } else {
128
168
  findings.push({
@@ -132,20 +172,10 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
132
172
  });
133
173
  }
134
174
 
135
- if (input.sources === null) {
136
- findings.push({ level: 'note', area: 'compute', message: 'no db/sql directory derived layer not declared, nothing to parse' });
137
- } else {
138
- try {
139
- const parsed = parseSqlSources(input.sources);
140
- findings.push({
141
- level: 'ok',
142
- area: 'compute',
143
- message: `${parsed.objects.length} derived object(s) parsed from ${input.sources.length} db/sql file(s)`,
144
- });
145
- for (const w of parsed.warnings) findings.push({ level: 'note', area: 'compute', message: w });
146
- } catch (err: any) {
147
- findings.push({ level: 'fail', area: 'compute', message: `db/sql does not parse: ${err.message}` });
148
- }
175
+ // B7 — db/sql is retired. Files still present would be silently unmanaged: fail
176
+ // with the migration path. (Absence is the normal state; nothing to report.)
177
+ if (input.sqlDirRetired) {
178
+ findings.push({ level: 'fail', area: 'compute', message: input.sqlDirRetired });
149
179
  }
150
180
 
151
181
  return findings;
@@ -177,14 +207,14 @@ export interface EphemeralComposeResult {
177
207
  export async function executeEphemeralCompose(
178
208
  adminUrl: string,
179
209
  models: ModelDescriptor[],
180
- sources: SourceFile[] | null,
181
- opts: { actor?: string | null; gitRef?: string | null } = {},
210
+ opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[] } = {},
182
211
  ): Promise<EphemeralComposeResult> {
183
212
  const database = `escheck_${process.pid}_${Date.now()}`;
184
213
  await createDatabase(adminUrl, database);
185
214
  try {
186
215
  const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
187
- sources,
216
+ declared: opts.declared,
217
+ sequences: opts.sequences,
188
218
  actor: opts.actor ?? 'db:check',
189
219
  gitRef: opts.gitRef ?? null,
190
220
  });
@@ -198,7 +228,7 @@ export async function executeEphemeralCompose(
198
228
  // The CLI shell.
199
229
  // ---------------------------------------------------------------------------
200
230
 
201
- const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', fail: '!' };
231
+ const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', warn: '~', fail: '!' };
202
232
 
203
233
  export async function dbCheckCommand(flags: Record<string, string>): Promise<void> {
204
234
  const modelsPath = resolveModelsPath(flags.models);
@@ -218,11 +248,30 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
218
248
  } catch {
219
249
  artifactSource = null;
220
250
  }
221
- const sources = await readSqlDirIfPresent(flags['sql-dir'] || DEFAULT_SQL_DIR);
251
+ // B7 tombstone a retired db/sql directory still carrying .sql files is a FAIL
252
+ // finding (the message carries the migration path), never a silent skip. The retired
253
+ // --sql-dir flag is itself a FAIL finding here (the sibling verbs refuse it outright);
254
+ // detector errors (an unreadable directory) surface as the finding, never disarm it.
255
+ const sqlDirRetired = flags['sql-dir']
256
+ ? await retiredSqlDirFlagRefusal(flags['sql-dir'])
257
+ : await retiredSqlDirAnywhere(flags.models).catch((err: any) => String(err?.message ?? err));
258
+
259
+ // The declared derived layer + sequences — the same stream every other verb consumes.
260
+ // A compile failure here (reachability, cycles) is a FAIL finding, not a shrug.
261
+ let declaredDb: DeclaredDerived | null = null;
262
+ let derivedError: string | undefined;
263
+ try {
264
+ declaredDb = await loadDeclaredDerived(flags.models);
265
+ } catch (err: any) {
266
+ derivedError = err.message;
267
+ }
222
268
 
223
- const findings = runStaticChecks({ modelsPath, models, modelsError, artifactPath, artifactSource, sources });
269
+ const findings = runStaticChecks({
270
+ modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
271
+ sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
272
+ });
224
273
  for (const f of findings) {
225
- (f.level === 'fail' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
274
+ (f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
226
275
  }
227
276
 
228
277
  let ephemeral: EphemeralComposeResult | null = null;
@@ -236,9 +285,11 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
236
285
  } else {
237
286
  step('Composing the declared state from scratch on an ephemeral database...');
238
287
  try {
239
- ephemeral = await executeEphemeralCompose(url, models!, sources, {
288
+ ephemeral = await executeEphemeralCompose(url, models!, {
240
289
  actor: process.env.USER ?? 'db:check',
241
290
  gitRef: currentGitRef(),
291
+ declared: declaredDb?.objects,
292
+ sequences: declaredDb?.sequences,
242
293
  });
243
294
  for (const line of ephemeral.report) info(` ${line}`);
244
295
  } catch (err: any) {
@@ -30,6 +30,8 @@ import { resolveConfig, opsFunction } from '../config.js';
30
30
  import { invokeAction } from '../aws.js';
31
31
  import { step, success, fail, info, warn } from '../output.js';
32
32
  import { loadModels } from './db-generate.js';
33
+ import { loadDeclaredDerived } from '../declared-derived.js';
34
+ import type { SequenceDescriptor } from '@everystack/model';
33
35
 
34
36
  export interface FingerprintStatus {
35
37
  live: string;
@@ -42,11 +44,12 @@ export interface FingerprintStatus {
42
44
  export async function computeFingerprintStatus(
43
45
  runner: QueryRunner,
44
46
  models: ModelDescriptor[] | null,
47
+ sequences?: SequenceDescriptor[],
45
48
  ): Promise<FingerprintStatus> {
46
49
  const snapshot = await introspectSchema(runner);
47
50
  const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
48
51
  const live = fingerprintLive(snapshot, contract).hash;
49
- const declared = models ? fingerprintModels(models).hash : null;
52
+ const declared = models ? fingerprintModels(models, { sequences }).hash : null;
50
53
  const unfingerprinted = mapUnfingerprintedRows((await runner(UNFINGERPRINTED_SQL)) as any[]);
51
54
  return { live, declared, match: declared === null ? null : live === declared, unfingerprinted };
52
55
  }
@@ -71,8 +74,10 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
71
74
 
72
75
  const modelsPath = resolveModelsPath(flags.models);
73
76
  let models: ModelDescriptor[] | null = null;
77
+ let sequences: SequenceDescriptor[] | undefined;
74
78
  try {
75
79
  models = await loadModels(modelsPath);
80
+ sequences = (await loadDeclaredDerived(flags.models))?.sequences;
76
81
  } catch {
77
82
  // Live-only mode: no models barrel — still useful (what fingerprint is this DB at?).
78
83
  }
@@ -89,7 +94,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
89
94
  }
90
95
 
91
96
  try {
92
- const status = await computeFingerprintStatus(runner, models);
97
+ const status = await computeFingerprintStatus(runner, models, sequences);
93
98
 
94
99
  if (flags.json === 'true') {
95
100
  console.log(JSON.stringify(status, null, 2));