@everystack/cli 0.4.93 → 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/CHANGELOG.md +25 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +111 -19
- 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 +32 -18
- package/src/cli/commands/db-plan.ts +12 -18
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/commands/db-sync.ts +11 -1
- package/src/cli/derived-plan.ts +7 -1
- package/src/cli/exec-digest.ts +61 -16
- package/src/cli/exec-run.ts +81 -2
- package/src/cli/git-descent.ts +204 -628
- package/src/cli/index.ts +1 -1
- package/src/cli/plain-output.ts +12 -6
- package/src/cli/state-apply.ts +2 -2
- package/src/cli/state-function-deps.ts +95 -0
- package/src/exec.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ 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.95] - 2026-09-26
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- descent reads schema_log and git ancestry, never historical models trees — no OOM, no minutes; when live has moved, the last recorded apply's commit must be in your history, and the schema_log note reads 'descent: drift-origin ...'
|
|
12
|
+
- a destructive db:apply that records no git commit is refused
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- read a schema_log written before fp_version and dirty existed
|
|
17
|
+
|
|
18
|
+
## [0.4.94] - 2026-09-24
|
|
19
|
+
|
|
20
|
+
### BREAKING CHANGES
|
|
21
|
+
|
|
22
|
+
- db:exec refuses to write the everystack schema
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- --baseline grant-checks what it adopts
|
|
27
|
+
- plain output names every grantee of a multi-grantee grant
|
|
28
|
+
- db:exec's DML-only guard sees rules, triggers, defaults, event triggers, extensions
|
|
29
|
+
- db:exec guard round 2 — deferred triggers, inherited ledgers, track_counts, search_path
|
|
30
|
+
- db:exec guard round 3 — shadowed catalogs, held cursors, re-deferred triggers
|
|
31
|
+
|
|
7
32
|
## [0.4.93] - 2026-09-24
|
|
8
33
|
|
|
9
34
|
### BREAKING CHANGES
|
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
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
type QueryRunner,
|
|
43
43
|
type AuthzContract,
|
|
44
44
|
} from './authz-contract';
|
|
45
|
-
import { INTROSPECTION_SESSION, rowsOrEmpty, type SessionRunner } from './session';
|
|
45
|
+
import { INTROSPECTION_SESSION, isSessionError, rowsOrEmpty, type SessionResult, type SessionRunner } from './session';
|
|
46
46
|
import {
|
|
47
47
|
introspectSchema,
|
|
48
48
|
assembleSchema,
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
import { validationPendingReason, pendingValidationsAreSafe } from './apply-refusals';
|
|
70
70
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
71
71
|
import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
|
|
72
|
-
import type { DescentLedgerRow } from './git-descent';
|
|
72
|
+
import type { DescentLedgerRow, LedgerRead } from './git-descent';
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* CALLER CONTRACT (fail-closed protection lives here, not inside executeApplyPlan):
|
|
@@ -108,6 +108,8 @@ export interface ApplyPlanOptions {
|
|
|
108
108
|
* degrades toward REVERT/refusal, never a false pass — and the server re-verifies the lock
|
|
109
109
|
* in its own one-session read. */
|
|
110
110
|
ledger: DescentLedgerRow[];
|
|
111
|
+
/** What the same read proved about the ledger — absent, present (with counts), or unreadable. */
|
|
112
|
+
ledgerRead: LedgerRead;
|
|
111
113
|
}) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
112
114
|
/**
|
|
113
115
|
* Destructive authority (brick 9, decisions 11+12): called only when the
|
|
@@ -141,12 +143,21 @@ export interface ApplyPlanOptions {
|
|
|
141
143
|
now?: () => number;
|
|
142
144
|
/**
|
|
143
145
|
* A note for the schema_log outcome (`applied — <note>`) carrying what descent decided: a
|
|
144
|
-
* honoured `--attest-lineage-lost` waiver, or a
|
|
145
|
-
*
|
|
146
|
+
* honoured `--attest-lineage-lost` waiver, or a DRIFT origin (`descent: drift-origin
|
|
147
|
+
* never-declared (…) | hand-edited since … | applied from a DIRTY worktree at …`) — the record that makes this row the origin
|
|
146
148
|
* every later apply descends from. A getter, because the decision is made inside `checkDescent`
|
|
147
149
|
* (which runs before the write) — the getter reads it at write time.
|
|
148
150
|
*/
|
|
149
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>;
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
export interface ApplyPlanResult {
|
|
@@ -257,6 +268,7 @@ export async function liveBaseFingerprint(session: SessionRunner): Promise<LiveB
|
|
|
257
268
|
/** The live base fingerprint AND the descent ledger, read in ONE session. */
|
|
258
269
|
export interface LiveBaseAndLedger extends LiveBaseFingerprint {
|
|
259
270
|
ledger: DescentLedgerRow[];
|
|
271
|
+
ledgerRead: LedgerRead;
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
/**
|
|
@@ -270,12 +282,85 @@ export interface LiveBaseAndLedger extends LiveBaseFingerprint {
|
|
|
270
282
|
* silently dropped every annotated apply from the ledger, which for a drift origin defeats the
|
|
271
283
|
* entire point of recording it.
|
|
272
284
|
*/
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
//
|
|
286
|
+
// `fp_version` and `dirty` are read through `to_jsonb` because a ledger written before they existed
|
|
287
|
+
// lacks the columns until the next apply adds them (ENSURE_RECONCILER_SQL). Such rows read NULL /
|
|
288
|
+
// false, which descent already skips as witnesses; naming the columns made the whole read fail.
|
|
289
|
+
const LEDGER_COLUMNS = `id, to_fingerprint, git_ref,
|
|
290
|
+
(to_jsonb(s) ->> 'fp_version')::int AS fp_version,
|
|
291
|
+
COALESCE((to_jsonb(s) ->> 'dirty')::boolean, false) AS dirty,
|
|
292
|
+
applied_at, outcome`;
|
|
293
|
+
|
|
294
|
+
export const SCHEMA_LOG_WITNESS_SQL = `SELECT ${LEDGER_COLUMNS}
|
|
295
|
+
FROM everystack.schema_log s
|
|
296
|
+
WHERE kind = 'state apply' AND outcome LIKE 'applied%'
|
|
276
297
|
ORDER BY id DESC
|
|
277
298
|
LIMIT 200`;
|
|
278
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The newest successful apply that recorded a commit — the row descent's ancestry check anchors on.
|
|
302
|
+
* Read on its own, uncapped, because 200 newer no-git applies would push it out of the witness set
|
|
303
|
+
* and turn a REVERT into a drift. Ordered by id, never applied_at: ids survive a dump restore, and
|
|
304
|
+
* clocks on different containers do not agree.
|
|
305
|
+
*/
|
|
306
|
+
export const SCHEMA_LOG_ANCHOR_SQL = `SELECT ${LEDGER_COLUMNS}
|
|
307
|
+
FROM everystack.schema_log s
|
|
308
|
+
WHERE kind = 'state apply' AND outcome LIKE 'applied%' AND git_ref IS NOT NULL
|
|
309
|
+
ORDER BY id DESC
|
|
310
|
+
LIMIT 1`;
|
|
311
|
+
|
|
312
|
+
/** A query that cannot fail on a missing ledger — so "absent" is never a read error in disguise. */
|
|
313
|
+
export const SCHEMA_LOG_PRESENT_SQL = `SELECT to_regclass('everystack.schema_log') IS NOT NULL AS present`;
|
|
314
|
+
|
|
315
|
+
/** Every successful state apply and every recorded refusal — ALL rows, not the capped witness set. */
|
|
316
|
+
export const SCHEMA_LOG_COUNTS_SQL = `SELECT
|
|
317
|
+
count(*) FILTER (WHERE kind = 'state apply' AND outcome LIKE 'applied%')::int AS applied,
|
|
318
|
+
count(*) FILTER (WHERE kind = 'state refusal')::int AS refusals
|
|
319
|
+
FROM everystack.schema_log`;
|
|
320
|
+
|
|
321
|
+
const LEDGER_STATEMENTS = [
|
|
322
|
+
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
323
|
+
{ sql: SCHEMA_LOG_PRESENT_SQL, allowFailure: true },
|
|
324
|
+
{ sql: SCHEMA_LOG_COUNTS_SQL, allowFailure: true },
|
|
325
|
+
{ sql: SCHEMA_LOG_ANCHOR_SQL, allowFailure: true },
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The witness rows and what the read proved about the ledger. A failed read is UNREADABLE, never
|
|
330
|
+
* empty: only a clean read may say no apply landed, or that none recorded a commit.
|
|
331
|
+
*/
|
|
332
|
+
export function ledgerFromSlots(
|
|
333
|
+
witness: SessionResult | undefined,
|
|
334
|
+
present: SessionResult | undefined,
|
|
335
|
+
counts: SessionResult | undefined,
|
|
336
|
+
anchor: SessionResult | undefined,
|
|
337
|
+
): { ledger: DescentLedgerRow[]; ledgerRead: LedgerRead } {
|
|
338
|
+
// Rows that were read are always kept: a witness does not become less true because a sibling
|
|
339
|
+
// read failed. Only the claims "no apply landed" and "none anchors" need every read to succeed.
|
|
340
|
+
const ledger = mapLedgerRows(rowsOrEmpty(witness));
|
|
341
|
+
for (const row of mapLedgerRows(rowsOrEmpty(anchor))) {
|
|
342
|
+
if (!ledger.some((r) => r.id === row.id)) ledger.push(row);
|
|
343
|
+
}
|
|
344
|
+
const unreadable = (slot: SessionResult | undefined, what: string) => ({
|
|
345
|
+
ledger,
|
|
346
|
+
ledgerRead: { kind: 'unreadable' as const, reason: isSessionError(slot) ? slot.message : `no ${what} answer` },
|
|
347
|
+
});
|
|
348
|
+
if (!Array.isArray(present) || present.length !== 1) return unreadable(present, 'presence');
|
|
349
|
+
if (present[0].present !== true) return { ledger, ledgerRead: { kind: 'absent' } };
|
|
350
|
+
if (!Array.isArray(witness)) return unreadable(witness, 'witness');
|
|
351
|
+
if (!Array.isArray(counts) || counts.length !== 1) return unreadable(counts, 'count');
|
|
352
|
+
if (!Array.isArray(anchor)) return unreadable(anchor, 'anchor');
|
|
353
|
+
return {
|
|
354
|
+
ledger,
|
|
355
|
+
ledgerRead: {
|
|
356
|
+
kind: 'present',
|
|
357
|
+
applied: Number(counts[0].applied),
|
|
358
|
+
refusals: Number(counts[0].refusals),
|
|
359
|
+
anchored: anchor.length > 0,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
279
364
|
function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
|
|
280
365
|
return rows.map((r) => ({
|
|
281
366
|
id: String(r.id),
|
|
@@ -288,13 +373,10 @@ function mapLedgerRows(rows: any[]): DescentLedgerRow[] {
|
|
|
288
373
|
}));
|
|
289
374
|
}
|
|
290
375
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
export async function readDescentLedger(session: SessionRunner): Promise<DescentLedgerRow[]> {
|
|
296
|
-
const [rows] = await session([{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true }], INTROSPECTION_SESSION);
|
|
297
|
-
return mapLedgerRows(rowsOrEmpty(rows));
|
|
376
|
+
/** The witness rows AND what the read proved about the ledger, in one session — what descent is handed. */
|
|
377
|
+
export async function readDescentLedgerRead(session: SessionRunner): Promise<{ ledger: DescentLedgerRow[]; ledgerRead: LedgerRead }> {
|
|
378
|
+
const [witness, present, counts, anchor] = await session(LEDGER_STATEMENTS, INTROSPECTION_SESSION);
|
|
379
|
+
return ledgerFromSlots(witness, present, counts, anchor);
|
|
298
380
|
}
|
|
299
381
|
|
|
300
382
|
/**
|
|
@@ -314,7 +396,7 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
314
396
|
// The default-privilege posture the plan's `defaultAclHash` is checked against — read in
|
|
315
397
|
// the SAME session as the lock's state, so the two gates describe one instant.
|
|
316
398
|
DEFAULT_ACL_SQL,
|
|
317
|
-
|
|
399
|
+
...LEDGER_STATEMENTS,
|
|
318
400
|
],
|
|
319
401
|
INTROSPECTION_SESSION,
|
|
320
402
|
);
|
|
@@ -331,8 +413,8 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
331
413
|
functions: (results[13] as any[]).map(contractFunctionRow),
|
|
332
414
|
defaultAcls: results[14] as any[],
|
|
333
415
|
});
|
|
334
|
-
const ledger =
|
|
335
|
-
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
416
|
+
const { ledger, ledgerRead } = ledgerFromSlots(results[15], results[16], results[17], results[18]);
|
|
417
|
+
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger, ledgerRead };
|
|
336
418
|
}
|
|
337
419
|
|
|
338
420
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
@@ -342,7 +424,7 @@ export async function executeApplyPlan(
|
|
|
342
424
|
plan: EdgePlan,
|
|
343
425
|
options: ApplyPlanOptions = {},
|
|
344
426
|
): Promise<ApplyPlanResult> {
|
|
345
|
-
const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
|
|
427
|
+
const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
|
|
346
428
|
|
|
347
429
|
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
348
430
|
// the RAW live hash directly — the same flavor as `from`. A plan without that flavor predates
|
|
@@ -381,8 +463,16 @@ export async function executeApplyPlan(
|
|
|
381
463
|
return { status: 'refused', liveFingerprint: live, reason };
|
|
382
464
|
}
|
|
383
465
|
|
|
466
|
+
// A destructive apply must record the commit it came from. Descent anchors on the newest apply
|
|
467
|
+
// WITH a commit, so a destructive row without one would be invisible to the next ancestry check.
|
|
468
|
+
if (plan.destructive > 0 && (options.gitRef ?? plan.gitRef) == null) {
|
|
469
|
+
const reason = 'a destructive apply must record the commit it came from, and this one has none — run db:plan and db:apply from the git checkout that declares your models.';
|
|
470
|
+
await recordRefusal(runner, plan, live, 'fast-forward rule', reason, options);
|
|
471
|
+
return { status: 'descent-refused', liveFingerprint: live, reason };
|
|
472
|
+
}
|
|
473
|
+
|
|
384
474
|
if (options.checkDescent) {
|
|
385
|
-
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
|
|
475
|
+
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger, ledgerRead });
|
|
386
476
|
if (!descent.ok) {
|
|
387
477
|
await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
|
|
388
478
|
return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
|
|
@@ -453,6 +543,8 @@ export async function executeApplyPlan(
|
|
|
453
543
|
// keep naming the file the operator reviewed.
|
|
454
544
|
const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
|
|
455
545
|
|
|
546
|
+
if (options.beforeStateBatch) await options.beforeStateBatch(toRun);
|
|
547
|
+
|
|
456
548
|
const result = await applyGeneratedStatements(runner, toRun, {
|
|
457
549
|
...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
|
|
458
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
|
|
@@ -37,15 +37,16 @@ import { planHash, PLAN_VERSION, APPLY_CONTRACT_VERSION, declaredGranteesOf, typ
|
|
|
37
37
|
import {
|
|
38
38
|
proveDescent,
|
|
39
39
|
decideDescentApply,
|
|
40
|
-
|
|
40
|
+
driftNotice,
|
|
41
|
+
newerOffHeadNotice,
|
|
41
42
|
attestationNote,
|
|
42
43
|
proveLineageLost,
|
|
43
44
|
realLineageProbe,
|
|
44
45
|
resolveAttestation,
|
|
45
46
|
type DescentLedgerRow,
|
|
47
|
+
type LedgerRead,
|
|
46
48
|
type LiveState,
|
|
47
49
|
type ProveVerdict,
|
|
48
|
-
type ProveDescentOptions,
|
|
49
50
|
} from '../git-descent';
|
|
50
51
|
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority';
|
|
51
52
|
import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
|
|
@@ -61,6 +62,7 @@ import path from 'node:path';
|
|
|
61
62
|
import { resolveModelsPath } from '../models-path';
|
|
62
63
|
import { loadModels } from './db-generate';
|
|
63
64
|
import { loadDeclaredDerived } from '../declared-derived';
|
|
65
|
+
import { createStateReferencedFunctions } from '../state-function-deps';
|
|
64
66
|
import { compileTableContract } from '../authz-compile';
|
|
65
67
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
|
|
66
68
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
|
|
@@ -68,7 +70,7 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
|
|
|
68
70
|
import { borrowedSessionRunner, type SessionRunner } from '../session';
|
|
69
71
|
import { resolveConfig, opsFunction } from '../config';
|
|
70
72
|
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
71
|
-
import { executeApplyPlan,
|
|
73
|
+
import { executeApplyPlan, readDescentLedgerRead, type ApplyPlanResult } from '../apply-execute';
|
|
72
74
|
import { estimateOpsRuntimeFit } from '../ops-fit';
|
|
73
75
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
74
76
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
@@ -412,24 +414,23 @@ function exitOnCatchUpFailure(report: CatchUpReport): asserts report is { ok: tr
|
|
|
412
414
|
*/
|
|
413
415
|
export function describeDescentVerdict(verdict: ProveVerdict, opts: { destructive?: boolean } = {}): { level: 'info' | 'warn'; text: string } | null {
|
|
414
416
|
switch (verdict.status) {
|
|
415
|
-
case 'proven':
|
|
417
|
+
case 'proven': {
|
|
416
418
|
// A squash proof names BOTH commits: the carrier that is an ancestor of HEAD, and the
|
|
417
419
|
// deploying commit that is not — announcing the carrier alone as "an ancestor" hid that
|
|
418
420
|
// the proof rests on tree identity (closing-gate review, 2026-09-07).
|
|
419
|
-
|
|
420
|
-
?
|
|
421
|
-
:
|
|
421
|
+
const text = verdict.via === 'squash' && verdict.witness
|
|
422
|
+
? `descent: proven via squash — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from; recorded in schema_log.`
|
|
423
|
+
: `descent: proven via ${verdict.via} — ${verdict.commit.slice(0, 12)} is an ancestor of HEAD, fast-forward.`;
|
|
424
|
+
const newer = newerOffHeadNotice(verdict);
|
|
425
|
+
return newer ? { level: 'warn', text: `${text} ${newer}` } : { level: 'info', text };
|
|
426
|
+
}
|
|
422
427
|
case 'fresh-target':
|
|
423
428
|
return { level: 'info', text: 'descent: fresh target (no tables) — nothing to protect, bootstrapping.' };
|
|
424
429
|
case 'no-git':
|
|
425
430
|
// On a destructive plan the decision refuses with its own message; do not double-report.
|
|
426
431
|
return opts.destructive ? null : { level: 'warn', text: 'descent: not a git checkout — the fast-forward rule cannot be verified here.' };
|
|
427
432
|
case 'drift':
|
|
428
|
-
return {
|
|
429
|
-
level: 'warn',
|
|
430
|
-
text: `NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — this reviewed plan is the ORIGIN. The fast-forward rule has nothing merged to protect (proven over a full, current clone); the origin is recorded in schema_log, and every later apply descends from it.`,
|
|
431
|
-
};
|
|
432
|
-
case 'declared-elsewhere':
|
|
433
|
+
return { level: 'warn', text: `${driftNotice(verdict.origin)} This reviewed plan is the ORIGIN; it is recorded in schema_log, and every later apply descends from it.` };
|
|
433
434
|
case 'revert':
|
|
434
435
|
case 'unknown-commit':
|
|
435
436
|
case 'unverifiable':
|
|
@@ -458,18 +459,17 @@ export async function proveDescentForApply(input: {
|
|
|
458
459
|
modelsPath: string;
|
|
459
460
|
live: LiveState;
|
|
460
461
|
ledger: DescentLedgerRow[];
|
|
462
|
+
/** What the ledger read proved. */
|
|
463
|
+
ledgerRead: LedgerRead;
|
|
461
464
|
destructive: boolean;
|
|
462
465
|
attestation?: string;
|
|
463
466
|
/** Where git runs. Default process.cwd(). */
|
|
464
467
|
cwd?: string;
|
|
465
|
-
/** Test seam: the barrel loader and materialize root (jest cannot dynamic-import a .ts barrel).
|
|
466
|
-
* Production passes nothing and rides the real loader. */
|
|
467
|
-
descent?: Pick<ProveDescentOptions, 'loader' | 'materializeRoot'>;
|
|
468
468
|
}): Promise<{ descentVerdict: { ok: true } | { ok: false; reason: string }; descentAttested?: string }> {
|
|
469
469
|
const cwd = input.cwd ?? process.cwd();
|
|
470
470
|
const verdict = await proveDescent(input.modelsPath, input.live, input.ledger, {
|
|
471
471
|
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
472
|
-
|
|
472
|
+
ledgerRead: input.ledgerRead,
|
|
473
473
|
});
|
|
474
474
|
reportDescentVerdict(verdict, { destructive: input.destructive });
|
|
475
475
|
// The attestation is resolved THROUGH git (a prefix, tag or HEAD~n → the full sha) before it is
|
|
@@ -569,12 +569,13 @@ async function applyPlanViaStage(
|
|
|
569
569
|
// commit is gone from every ref.
|
|
570
570
|
const { snapshot, contract } = readPair.state;
|
|
571
571
|
step("Descent: reading the stage's schema_log ledger...");
|
|
572
|
-
const ledger = await
|
|
572
|
+
const { ledger, ledgerRead } = await readDescentLedgerRead(lambdaSessionRunner(region, fn));
|
|
573
573
|
step("Descent: proving the fast-forward rule against the ledger...");
|
|
574
574
|
const { descentVerdict, descentAttested } = await proveDescentForApply({
|
|
575
575
|
modelsPath: resolveModelsPath(flags.models),
|
|
576
576
|
live: { snapshot, contract },
|
|
577
577
|
ledger,
|
|
578
|
+
ledgerRead,
|
|
578
579
|
destructive: plan.destructive > 0,
|
|
579
580
|
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
580
581
|
});
|
|
@@ -1119,12 +1120,25 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1119
1120
|
},
|
|
1120
1121
|
dirty: worktreeDirty(resolveModelsPath(flags.models)),
|
|
1121
1122
|
descentNote: () => descentAttested,
|
|
1122
|
-
|
|
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
|
+
}),
|
|
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({
|
|
1125
1138
|
modelsPath: resolveModelsPath(flags.models),
|
|
1126
1139
|
live,
|
|
1127
1140
|
ledger: live.ledger,
|
|
1141
|
+
ledgerRead: live.ledgerRead,
|
|
1128
1142
|
destructive: plan.destructive > 0,
|
|
1129
1143
|
...(attestLineageLost ? { attestation: attestLineageLost } : {}),
|
|
1130
1144
|
});
|
|
@@ -50,10 +50,10 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
|
50
50
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
51
51
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
52
52
|
import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy, declaredGranteesOf } from '../edge-plan';
|
|
53
|
-
import { proveDescent,
|
|
53
|
+
import { proveDescent, driftNotice, newerOffHeadNotice, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
54
|
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
55
|
import { predictLiveState } from '../edge-plan';
|
|
56
|
-
import {
|
|
56
|
+
import { readDescentLedgerRead } from '../apply-execute';
|
|
57
57
|
import { planBackfills, readBackfillLog } from '../backfill';
|
|
58
58
|
import { readSqlDirIfPresent } from './db-sync';
|
|
59
59
|
import { currentGitRef, classifyGeneratedStatements, classifyDestructive } from '../state-apply';
|
|
@@ -115,25 +115,20 @@ export function describePlanDescent(
|
|
|
115
115
|
return `db:apply from this checkout will refuse this plan — ${proof.reason}`;
|
|
116
116
|
};
|
|
117
117
|
switch (verdict.status) {
|
|
118
|
-
case 'proven':
|
|
118
|
+
case 'proven': {
|
|
119
119
|
// A squash proof is the one PROVEN shape whose deploying commit is not in the operator's
|
|
120
120
|
// history — say so where a human reads the plan, as the apply record will.
|
|
121
|
-
|
|
121
|
+
const lines = verdict.via === 'squash' && verdict.witness
|
|
122
122
|
? [`descent: fast-forward proven via a squashed successor — ${verdict.commit.slice(0, 12)} (an ancestor of HEAD) carries the models tree of the deploying commit ${verdict.witness.slice(0, 12)}, which HEAD does not descend from. db:apply will record that.`]
|
|
123
123
|
: [];
|
|
124
|
+
const newer = newerOffHeadNotice(verdict);
|
|
125
|
+
return newer ? [...lines, newer] : lines;
|
|
126
|
+
}
|
|
124
127
|
case 'fresh-target':
|
|
125
128
|
case 'no-git':
|
|
126
129
|
return [];
|
|
127
130
|
case 'drift':
|
|
128
|
-
return [
|
|
129
|
-
`descent: NO COMMIT ON ANY REF DECLARES THIS DATABASE (${describeDriftOrigin(verdict.origin, { human: true })}) — proven over a full, current clone.`,
|
|
130
|
-
'db:apply will treat this reviewed plan as the ORIGIN: it applies, warns, and records the origin in schema_log; every later apply descends from it.',
|
|
131
|
-
];
|
|
132
|
-
case 'declared-elsewhere':
|
|
133
|
-
return [
|
|
134
|
-
`descent: ${verdict.reason}`,
|
|
135
|
-
'db:apply from this checkout will refuse this plan — fetch and merge or rebase onto that commit, then re-mint.',
|
|
136
|
-
];
|
|
131
|
+
return [driftNotice(verdict.origin), 'db:apply will treat this reviewed plan as the ORIGIN: it applies, warns, and records the origin in schema_log; every later apply descends from it.'];
|
|
137
132
|
case 'revert':
|
|
138
133
|
return [`descent: ${verdict.reason}`, applyWillDo(verdict.commit)];
|
|
139
134
|
case 'unknown-commit': {
|
|
@@ -149,9 +144,8 @@ export function describePlanDescent(
|
|
|
149
144
|
];
|
|
150
145
|
}
|
|
151
146
|
case 'unverifiable':
|
|
152
|
-
// NOT "rebase first" — nothing is wrong with the checkout or the database. The
|
|
153
|
-
|
|
154
|
-
return [`descent: ${verdict.reason}`, 'db:apply from this checkout will refuse this plan until that search completes — fetch or prune as it says, then re-mint.'];
|
|
147
|
+
// NOT "rebase first" — nothing is wrong with the checkout or the database. The ledger could not be read.
|
|
148
|
+
return [`descent: ${verdict.reason}`, 'db:apply will refuse this plan until the ledger can be read — retry, then re-mint.'];
|
|
155
149
|
default: {
|
|
156
150
|
// Compile-time exhaustiveness; at runtime (a verdict shape from outside this compile unit)
|
|
157
151
|
// an empty warning list is the safe value — the caller iterates it.
|
|
@@ -433,8 +427,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
433
427
|
// the very strand apply now PROVES via the ledger and applies, telling the operator the
|
|
434
428
|
// opposite of what happens. proveDescent over the same ledger apply reads keeps them agreed.
|
|
435
429
|
if (plan.statements.length > 0) {
|
|
436
|
-
const ledger = await
|
|
437
|
-
const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, {});
|
|
430
|
+
const { ledger, ledgerRead } = await readDescentLedgerRead(session);
|
|
431
|
+
const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, { ledgerRead });
|
|
438
432
|
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
439
433
|
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
440
434
|
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
@@ -54,8 +54,8 @@ import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
|
54
54
|
*
|
|
55
55
|
* db:pull is the only way an existing database comes in. What happens next is decided by
|
|
56
56
|
* db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
|
|
57
|
-
* is (adopted —
|
|
58
|
-
* it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
57
|
+
* is (adopted — nothing to apply; the first real change applies as the never-declared origin, and
|
|
58
|
+
* every later apply descends from it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
59
59
|
* to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
|
|
60
60
|
* (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
|
|
61
61
|
* refusal-runbook lint reads them.
|
|
@@ -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,
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -509,7 +509,13 @@ export function planReconcile(
|
|
|
509
509
|
// Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
|
|
510
510
|
// and running the checks NOW means a migration converges (grants ride the same apply)
|
|
511
511
|
// instead of surfacing on the next plan.
|
|
512
|
-
|
|
512
|
+
//
|
|
513
|
+
// Baselined objects join it too. A baseline trusts the STRUCTURE it cannot verify (source text
|
|
514
|
+
// and live deparse live in different hash spaces); the grants it CAN verify, so it never trusts
|
|
515
|
+
// them. Without this, an object restored with default privileges — every function EXECUTE to
|
|
516
|
+
// PUBLIC, SECURITY DEFINER ones included — was adopted with its open grants recorded as fine.
|
|
517
|
+
// (needsBaseline objects stay out: nothing is adopted, so nothing is touched.)
|
|
518
|
+
const lensSet = new Set([...skipped, ...rebaseline, ...backfill, ...baseline]);
|
|
513
519
|
for (const src of source.objects) {
|
|
514
520
|
if (!lensSet.has(src.identity)) continue;
|
|
515
521
|
if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
|