@everystack/cli 0.4.95 → 0.4.97

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.97] - 2026-09-26
8
+
9
+ ### Fixed
10
+
11
+ - revoke sessions when users are blocked
12
+ - converge literal and owner policy predicates
13
+ - precreate missing function overloads
14
+
15
+ ### Changed
16
+
17
+ - reuse declared derived during apply
18
+
19
+ ## [0.4.96] - 2026-09-26
20
+
21
+ ### BREAKING CHANGES
22
+
23
+ - defineModel refuses owner or via under a no-session audience
24
+
25
+ ### Fixed
26
+
27
+ - a policy that calls a declared function builds in one run
28
+ - refuse role: 'anon' combined with owner or via
29
+ - db:pull never renders role: 'anon' with owner:
30
+
7
31
  ## [0.4.95] - 2026-09-26
8
32
 
9
33
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.95",
3
+ "version": "0.4.97",
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>",
@@ -135,7 +135,7 @@
135
135
  "structured-headers": "1.0.1",
136
136
  "tsx": "4.21.0",
137
137
  "typescript": "5.9.3",
138
- "@everystack/model": "0.4.19"
138
+ "@everystack/model": "0.4.20"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "@everystack/server": ">=0.4.0",
@@ -149,6 +149,15 @@ export interface ApplyPlanOptions {
149
149
  * (which runs before the write) — the getter reads it at write time.
150
150
  */
151
151
  descentNote?: () => string | undefined;
152
+ /**
153
+ * Called with the statements about to run, AFTER every gate above has passed and BEFORE the
154
+ * batch — so a refused apply writes nothing through it. db:apply's direct lane uses it to
155
+ * create the declared functions a policy in the plan calls (state-function-deps.ts), which on
156
+ * a fresh target do not exist until the derived catch-up. Functions are not in the base
157
+ * fingerprint, so this moves neither the lock nor the verify-after endpoint. Omitted, the
158
+ * apply is unchanged (the ops-Lambda lane does not pass it).
159
+ */
160
+ beforeStateBatch?: (statements: readonly string[]) => Promise<unknown>;
152
161
  }
153
162
 
154
163
  export interface ApplyPlanResult {
@@ -534,6 +543,8 @@ export async function executeApplyPlan(
534
543
  // keep naming the file the operator reviewed.
535
544
  const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
536
545
 
546
+ if (options.beforeStateBatch) await options.beforeStateBatch(toRun);
547
+
537
548
  const result = await applyGeneratedStatements(runner, toRun, {
538
549
  ...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
539
550
  actor: options.actor,
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract';
25
- import { parenthesizeOnce } from './authz-contract';
25
+ import { parenthesizeOnce, isBareCall } from './authz-contract';
26
26
  import { normalizeDeparsedExpr } from './deparse-normal';
27
27
 
28
28
  export interface CompileOptions {
@@ -77,6 +77,16 @@ function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: st
77
77
  return `(${sqlColumn} = (${ce})::${castForType(type)})`;
78
78
  }
79
79
 
80
+ // A hand-written predicate can spell the same inline JWT owner check as `owner:`. Match only
81
+ // that complete shape, then emit the one PostgreSQL form the structured shorthand uses.
82
+ const HAND_WRITTEN_OWNER_RE =
83
+ /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(\(current_setting\('request\.jwt\.claims'(?:::text)?,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'(?:::text)?\)::([a-z]+)\)?$/i;
84
+
85
+ function normalizeHandWrittenOwnerPredicate(text: string): string | null {
86
+ const match = HAND_WRITTEN_OWNER_RE.exec(text);
87
+ return match ? ownerPredicate(match[1], match[3], match[2]) : null;
88
+ }
89
+
80
90
  /** The soft-delete guard, in deparser normal form: `(deleted_at IS NULL)`. */
81
91
  function softDeleteGuard(sqlColumn: string): string {
82
92
  return `(${sqlColumn} IS NULL)`;
@@ -312,8 +322,12 @@ function rawPredicate(value: unknown, where: string): string | null {
312
322
  // Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
313
323
  // (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
314
324
  // differ from the live one by a single layer of parens — enough for the reconciler to
315
- // plan a DROP + CREATE that changed nothing.
316
- return parenthesizeOnce(text.trim());
325
+ // plan a DROP + CREATE that changed nothing. A bare call is the exception: the deparser
326
+ // prints `has_permission()` with no outer parens, so wrapping it is that same churn.
327
+ const t = text.trim();
328
+ const owner = normalizeHandWrittenOwnerPredicate(t);
329
+ if (owner) return owner;
330
+ return isBareCall(t) || t === 'true' || t === 'false' ? t : parenthesizeOnce(t);
317
331
  }
318
332
 
319
333
  /**
@@ -271,6 +271,40 @@ export function isWrappedExpression(s: string): boolean {
271
271
  return depth === 0;
272
272
  }
273
273
 
274
+ const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)`;
275
+ const CALL_HEAD = new RegExp(String.raw`^(?:${IDENT}\s*\.\s*)?(${IDENT})\s*\(`);
276
+ /** Keyword forms that look like `name(` but the deparser prints wrapped. */
277
+ const WRAPPED_KEYWORDS = new Set(['exists', 'not']);
278
+
279
+ /**
280
+ * True when `s` is ONE call — `name(…)` or `schema.name(…)` — spanning the whole text.
281
+ *
282
+ * `pg_get_expr` prints such a predicate with NO outer parens (measured, PG17: `g()`, `a.f()`,
283
+ * `COALESCE(g(), false)`), unlike an operator expression, which it wraps. A call is atomic, so
284
+ * leaving it bare can never re-associate when it is AND-ed into a larger predicate. The scan
285
+ * skips string literals and quoted identifiers, so `f(')') OR g()` is not mistaken for one call.
286
+ */
287
+ export function isBareCall(s: string): boolean {
288
+ const head = CALL_HEAD.exec(s);
289
+ if (!head || WRAPPED_KEYWORDS.has(head[1].toLowerCase())) return false;
290
+ let depth = 0;
291
+ for (let i = head[0].length - 1; i < s.length; i++) {
292
+ const c = s[i];
293
+ if (c === "'" || c === '"') {
294
+ const close = s.indexOf(c, i + 1);
295
+ if (close < 0) return false;
296
+ i = close; // a doubled quote re-enters here and is skipped as an empty run
297
+ continue;
298
+ }
299
+ if (c === '(') depth++;
300
+ else if (c === ')') {
301
+ depth--;
302
+ if (depth === 0) return i === s.length - 1;
303
+ }
304
+ }
305
+ return false;
306
+ }
307
+
274
308
  /**
275
309
  * A predicate parenthesized exactly once — the normal form both producers must agree on.
276
310
  *
@@ -25,7 +25,7 @@
25
25
  * comment: the comment costs a human five minutes, the ability costs them a privilege.
26
26
  */
27
27
 
28
- import { EXTRA_PRIVILEGES } from '@everystack/model';
28
+ import { EXTRA_PRIVILEGES, sessionlessAudience } from '@everystack/model';
29
29
  import { holdsPrivilege, holdsTablePrivilege } from './authz-contract';
30
30
  import type { TableContract, PolicyContract } from './authz-contract';
31
31
 
@@ -367,9 +367,19 @@ function renderColumnAbility(
367
367
  notes: string[],
368
368
  ): string | null {
369
369
  const using = policy.using ?? null;
370
- const owner = parseOwnerPredicate(using);
371
- const parts: string[] = [];
372
370
  const role = policyRoleForGrantee(grantee);
371
+ // defineModel refuses a no-session audience with `owner:` (see sessionlessAudience). An anon
372
+ // owner-shaped predicate is therefore carried verbatim as `sql:` below, and disclosed.
373
+ const parsed = parseOwnerPredicate(using);
374
+ const sessionless = parsed ? sessionlessAudience({ role }) : null;
375
+ const owner = sessionless ? null : parsed;
376
+ if (sessionless) {
377
+ notes.push(`verbatim-sql: policy "${policy.name}" (${action === 'read' ? 'SELECT' : 'UPDATE'}) is an owner check TO ${role}.`);
378
+ notes.push(` ${role} has no session, so it matches no row. ${sessionless} with owner: is not a`);
379
+ notes.push(` model shape, so the predicate is transcribed VERBATIM above. Drop the policy or`);
380
+ notes.push(` move the column grant to authenticated.`);
381
+ }
382
+ const parts: string[] = [];
373
383
 
374
384
  // A distinct WITH CHECK on a column UPDATE must ride as `check:`, and the compiler REFUSES a
375
385
  // role-less column ability that carries any row predicate (F-A12: a column ability has nowhere
@@ -62,6 +62,7 @@ import path from 'node:path';
62
62
  import { resolveModelsPath } from '../models-path';
63
63
  import { loadModels } from './db-generate';
64
64
  import { loadDeclaredDerived } from '../declared-derived';
65
+ import { createStateReferencedFunctions } from '../state-function-deps';
65
66
  import { compileTableContract } from '../authz-compile';
66
67
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
67
68
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
@@ -1055,6 +1056,11 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1055
1056
 
1056
1057
  let landed: ApplyPlanResult | undefined;
1057
1058
  let catchUp: CatchUpReport = { ok: true, statements: [] };
1059
+ // Both gates below need the declared derived bundle. Keep its existing lazy timing — the
1060
+ // baseline gate is still the first reader — but never evaluate the models barrel twice.
1061
+ let declaredDbPromise: ReturnType<typeof loadDeclaredDerived> | undefined;
1062
+ const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
1063
+ declaredDbPromise ??= loadDeclaredDerived(flags.models);
1058
1064
  try {
1059
1065
  // The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
1060
1066
  if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
@@ -1109,7 +1115,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1109
1115
  verifyAuthzBaseline: async ({ contract }) => {
1110
1116
  const stageName = flags.stage || 'local';
1111
1117
  const models = await loadModels(resolveModelsPath(flags.models));
1112
- const governedRoles = (await loadDeclaredDerived(flags.models))?.governedRoles ?? [];
1118
+ const governedRoles = (await declaredDb())?.governedRoles ?? [];
1113
1119
  const declaredAuthz = { tables: models.map((m) => compileTableContract(m, {})), functions: [] };
1114
1120
  const exemptions = ungovernedGrants(contract, governedRoleSet(declaredAuthz, governedRoles));
1115
1121
  const violations = checkAgainstBaseline(await readBaselineFile(), stageName, exemptions);
@@ -1119,6 +1125,18 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1119
1125
  },
1120
1126
  dirty: worktreeDirty(resolveModelsPath(flags.models)),
1121
1127
  descentNote: () => descentAttested,
1128
+ // A declared function a policy in this plan calls must exist before the policy — on a
1129
+ // fresh target it would otherwise arrive only with the catch-up below. Only the called,
1130
+ // missing ones; --state-only asked for no derived writes, so it is skipped there.
1131
+ ...(flags['state-only'] === 'true' ? {} : {
1132
+ beforeStateBatch: async (statements: readonly string[]) => {
1133
+ const declared = await declaredDb();
1134
+ const created = await createStateReferencedFunctions(runner, session, statements, declared?.objects ?? [], {
1135
+ actor: process.env.USER ?? null, gitRef: currentGitRef() ?? plan.gitRef,
1136
+ });
1137
+ if (created.length > 0) info(`created ${created.length} declared function(s) the plan's policies call, before the plan: ${created.join(', ')}`);
1138
+ },
1139
+ }),
1122
1140
  checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }) => {
1123
1141
  step('Descent: proving the fast-forward rule against the schema_log ledger...');
1124
1142
  const result = await proveDescentForApply({
@@ -52,6 +52,7 @@ import { borrowedSessionRunner, type SessionRunner } from '../session';
52
52
  import { planBackfills, readBackfillLog } from '../backfill';
53
53
  import { resolveModelsPath } from '../models-path';
54
54
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile';
55
+ import { createStateReferencedFunctions } from '../state-function-deps';
55
56
  import { loadModels } from './db-generate';
56
57
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
57
58
  import { reportPipelineLastRun } from './pipeline-run';
@@ -112,7 +113,8 @@ export interface SyncRun {
112
113
 
113
114
  /**
114
115
  * One sync: state first (derived objects may depend on new columns), then
115
- * compute, then the verdict. Pure orchestration over an injected QueryRunner;
116
+ * compute, then the verdict. The exception is the declared functions the
117
+ * state batch itself calls, which go just before it (state-function-deps.ts). Pure orchestration over an injected QueryRunner;
116
118
  * the CLI shell below owns flags, files, and exits.
117
119
  */
118
120
  async function syncOnce(
@@ -131,6 +133,14 @@ async function syncOnce(
131
133
  });
132
134
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements), pass);
133
135
 
136
+ // The one derived → state edge: a declared function a pending policy (or default, or check)
137
+ // calls must exist before that statement is created. Only those functions, only when absent.
138
+ if (options.declared?.length) {
139
+ await createStateReferencedFunctions(runner, session, statements, options.declared, {
140
+ actor: options.actor, gitRef: options.gitRef, now: options.now,
141
+ });
142
+ }
143
+
134
144
  const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
135
145
  allowDrops: options.allowDrops, sequences: options.sequences, extensions: options.extensions,
136
146
  actor: options.actor, gitRef: options.gitRef, now: options.now,
@@ -0,0 +1,101 @@
1
+ /**
2
+ * state-function-deps — the declared functions a pending STATE batch calls, created first.
3
+ *
4
+ * Sync and apply run STATE (tables, RLS, policies, grants) before the DERIVED layer
5
+ * (functions, views, matviews, triggers). That order is right for views and triggers, which
6
+ * read tables state creates. It is wrong for one edge: an RLS policy's predicate is resolved
7
+ * when the policy is CREATED, so `can('read', { where: sql\`has_permission()\` })` over a
8
+ * declared `has_permission()` fails on a fresh database with `function has_permission() does
9
+ * not exist` — the function only arrives in the second half.
10
+ *
11
+ * So the edge is followed, not the layers swapped: a declared function whose name appears in
12
+ * a pending state statement, and which the target does not have yet, is created BEFORE the
13
+ * state batch. Everything else keeps the ordinary order.
14
+ *
15
+ * The reference test is TEXT, on purpose. Predicates are already compiled and diffed as text,
16
+ * and a false positive is harmless: creating a missing function early is exactly what the
17
+ * ordinary pass would do a moment later.
18
+ *
19
+ * A function whose SIGNATURE names a declared relation (`RETURNS SETOF some_view`) cannot go
20
+ * first — PostgreSQL resolves argument and return types at creation, and the relation may be
21
+ * created by state or by a view later in the derived pass. It stays behind, as in db:build.
22
+ */
23
+
24
+ import type { QueryRunner } from './authz-contract';
25
+ import type { SessionRunner } from './session';
26
+ import type { SourceObject } from './derived-source';
27
+ import { executeReconcile } from './commands/db-reconcile';
28
+ import { functionIdentity } from './pg-argtypes';
29
+
30
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+
32
+ /** A call to `schema.name(` or `name(`, quoted or not, not preceded by another qualifier. */
33
+ function callPattern(schema: string, name: string): RegExp {
34
+ const q = (id: string) => `"?${escapeRe(id)}"?`;
35
+ return new RegExp(`(?:^|[^\\w$."])(?:${q(schema)}\\s*\\.\\s*)?${q(name)}\\s*\\(`, 'i');
36
+ }
37
+
38
+ /**
39
+ * The declared functions a state batch names by call — the pure half, no database.
40
+ * Only functions that CAN go first: no signature dependency on a declared relation.
41
+ */
42
+ export function stateReferencedFunctions(statements: readonly string[], declared: readonly SourceObject[]): SourceObject[] {
43
+ if (statements.length === 0) return [];
44
+ const text = statements.join('\n');
45
+ return declared.filter((o) =>
46
+ o.kind === 'function'
47
+ && !o.signatureDeps?.length
48
+ && callPattern(o.schema, o.name).test(text));
49
+ }
50
+
51
+ const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
52
+
53
+ /**
54
+ * Create, before the state batch, the declared functions it calls that the target lacks.
55
+ * Returns the identities created (empty when nothing needed to move).
56
+ *
57
+ * The reconcile runs with the FULL declared set and `only` scoped to these identities, never a
58
+ * subset: a subset would read every other managed object as "removed from source" and drop it.
59
+ * A function that already exists is left to the ordinary after-state pass, so an existing
60
+ * database's replace/drift handling is unchanged by this.
61
+ *
62
+ * A SQL-language body that reads a table this same state batch creates is a forward reference,
63
+ * not an error: the reconcile runs its batch under `SET LOCAL check_function_bodies = off`.
64
+ */
65
+ export async function createStateReferencedFunctions(
66
+ runner: QueryRunner,
67
+ session: SessionRunner,
68
+ statements: readonly string[],
69
+ declared: readonly SourceObject[],
70
+ options: { actor?: string | null; gitRef?: string | null; now?: () => number } = {},
71
+ ): Promise<string[]> {
72
+ const referenced = stateReferencedFunctions(statements, declared);
73
+ if (referenced.length === 0) return [];
74
+
75
+ const live = await runner(
76
+ `SELECT n.nspname AS schema, p.proname AS name,
77
+ (SELECT COALESCE(string_agg(pg_catalog.format_type(t.oid, NULL), ', ' ORDER BY t.ord), '')
78
+ FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS t(oid, ord)) AS args
79
+ FROM pg_proc p
80
+ JOIN pg_namespace n ON n.oid = p.pronamespace
81
+ WHERE p.proname IN (${[...new Set(referenced.map((o) => o.name))].map(literal).join(', ')})`,
82
+ );
83
+ const present = new Set(live.map((r: any) => functionIdentity(
84
+ String(r.schema), String(r.name), String(r.args).trim() ? String(r.args).split(',').map((arg) => arg.trim()) : [],
85
+ )));
86
+ const missing = referenced.filter((o) => !present.has(o.identity));
87
+ if (missing.length === 0) return [];
88
+
89
+ const run = await executeReconcile(runner, session, {
90
+ declared: [...declared],
91
+ only: missing.map((o) => o.identity),
92
+ apply: true,
93
+ actor: options.actor ?? null,
94
+ gitRef: options.gitRef ?? null,
95
+ now: options.now,
96
+ });
97
+ if (run.refusal) {
98
+ throw new Error(`could not create the functions the state batch calls (${missing.map((o) => o.identity).join(', ')}) before it: ${run.refusal}`);
99
+ }
100
+ return missing.map((o) => o.identity);
101
+ }