@everystack/cli 0.4.95 → 0.4.96
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/package.json +2 -2
- package/src/cli/apply-execute.ts +11 -0
- package/src/cli/authz-compile.ts +5 -3
- package/src/cli/authz-contract.ts +34 -0
- package/src/cli/authz-derive.ts +13 -3
- package/src/cli/commands/db-apply.ts +13 -0
- package/src/cli/commands/db-sync.ts +11 -1
- package/src/cli/state-function-deps.ts +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.96",
|
|
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.
|
|
138
|
+
"@everystack/model": "0.4.20"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
141
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -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,
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -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 {
|
|
@@ -312,8 +312,10 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
312
312
|
// Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
|
|
313
313
|
// (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
|
|
314
314
|
// 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
|
-
|
|
315
|
+
// plan a DROP + CREATE that changed nothing. A bare call is the exception: the deparser
|
|
316
|
+
// prints `has_permission()` with no outer parens, so wrapping it is that same churn.
|
|
317
|
+
const t = text.trim();
|
|
318
|
+
return isBareCall(t) ? t : parenthesizeOnce(t);
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
/**
|
|
@@ -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
|
*
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -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';
|
|
@@ -1119,6 +1120,18 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1119
1120
|
},
|
|
1120
1121
|
dirty: worktreeDirty(resolveModelsPath(flags.models)),
|
|
1121
1122
|
descentNote: () => descentAttested,
|
|
1123
|
+
// A declared function a policy in this plan calls must exist before the policy — on a
|
|
1124
|
+
// fresh target it would otherwise arrive only with the catch-up below. Only the called,
|
|
1125
|
+
// missing ones; --state-only asked for no derived writes, so it is skipped there.
|
|
1126
|
+
...(flags['state-only'] === 'true' ? {} : {
|
|
1127
|
+
beforeStateBatch: async (statements: readonly string[]) => {
|
|
1128
|
+
const declaredDb = await loadDeclaredDerived(flags.models);
|
|
1129
|
+
const created = await createStateReferencedFunctions(runner, session, statements, declaredDb?.objects ?? [], {
|
|
1130
|
+
actor: process.env.USER ?? null, gitRef: currentGitRef() ?? plan.gitRef,
|
|
1131
|
+
});
|
|
1132
|
+
if (created.length > 0) info(`created ${created.length} declared function(s) the plan's policies call, before the plan: ${created.join(', ')}`);
|
|
1133
|
+
},
|
|
1134
|
+
}),
|
|
1122
1135
|
checkDescent: async (live: LiveState & { fingerprint: string; ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }) => {
|
|
1123
1136
|
step('Descent: proving the fast-forward rule against the schema_log ledger...');
|
|
1124
1137
|
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.
|
|
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,95 @@
|
|
|
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
|
+
|
|
29
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
30
|
+
|
|
31
|
+
/** A call to `schema.name(` or `name(`, quoted or not, not preceded by another qualifier. */
|
|
32
|
+
function callPattern(schema: string, name: string): RegExp {
|
|
33
|
+
const q = (id: string) => `"?${escapeRe(id)}"?`;
|
|
34
|
+
return new RegExp(`(?:^|[^\\w$."])(?:${q(schema)}\\s*\\.\\s*)?${q(name)}\\s*\\(`, 'i');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The declared functions a state batch names by call — the pure half, no database.
|
|
39
|
+
* Only functions that CAN go first: no signature dependency on a declared relation.
|
|
40
|
+
*/
|
|
41
|
+
export function stateReferencedFunctions(statements: readonly string[], declared: readonly SourceObject[]): SourceObject[] {
|
|
42
|
+
if (statements.length === 0) return [];
|
|
43
|
+
const text = statements.join('\n');
|
|
44
|
+
return declared.filter((o) =>
|
|
45
|
+
o.kind === 'function'
|
|
46
|
+
&& !o.signatureDeps?.length
|
|
47
|
+
&& callPattern(o.schema, o.name).test(text));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create, before the state batch, the declared functions it calls that the target lacks.
|
|
54
|
+
* Returns the identities created (empty when nothing needed to move).
|
|
55
|
+
*
|
|
56
|
+
* The reconcile runs with the FULL declared set and `only` scoped to these identities, never a
|
|
57
|
+
* subset: a subset would read every other managed object as "removed from source" and drop it.
|
|
58
|
+
* A function that already exists is left to the ordinary after-state pass, so an existing
|
|
59
|
+
* database's replace/drift handling is unchanged by this.
|
|
60
|
+
*
|
|
61
|
+
* A SQL-language body that reads a table this same state batch creates is a forward reference,
|
|
62
|
+
* not an error: the reconcile runs its batch under `SET LOCAL check_function_bodies = off`.
|
|
63
|
+
*/
|
|
64
|
+
export async function createStateReferencedFunctions(
|
|
65
|
+
runner: QueryRunner,
|
|
66
|
+
session: SessionRunner,
|
|
67
|
+
statements: readonly string[],
|
|
68
|
+
declared: readonly SourceObject[],
|
|
69
|
+
options: { actor?: string | null; gitRef?: string | null; now?: () => number } = {},
|
|
70
|
+
): Promise<string[]> {
|
|
71
|
+
const referenced = stateReferencedFunctions(statements, declared);
|
|
72
|
+
if (referenced.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const live = await runner(
|
|
75
|
+
`SELECT n.nspname AS schema, p.proname AS name FROM pg_proc p
|
|
76
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
77
|
+
WHERE p.proname IN (${[...new Set(referenced.map((o) => o.name))].map(literal).join(', ')})`,
|
|
78
|
+
);
|
|
79
|
+
const present = new Set(live.map((r: any) => `${r.schema}.${r.name}`));
|
|
80
|
+
const missing = referenced.filter((o) => !present.has(`${o.schema}.${o.name}`));
|
|
81
|
+
if (missing.length === 0) return [];
|
|
82
|
+
|
|
83
|
+
const run = await executeReconcile(runner, session, {
|
|
84
|
+
declared: [...declared],
|
|
85
|
+
only: missing.map((o) => o.identity),
|
|
86
|
+
apply: true,
|
|
87
|
+
actor: options.actor ?? null,
|
|
88
|
+
gitRef: options.gitRef ?? null,
|
|
89
|
+
now: options.now,
|
|
90
|
+
});
|
|
91
|
+
if (run.refusal) {
|
|
92
|
+
throw new Error(`could not create the functions the state batch calls (${missing.map((o) => o.identity).join(', ')}) before it: ${run.refusal}`);
|
|
93
|
+
}
|
|
94
|
+
return missing.map((o) => o.identity);
|
|
95
|
+
}
|