@everystack/cli 0.4.71 → 0.4.73

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,18 @@ 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.73] - 2026-09-04
8
+
9
+ ### Fixed
10
+
11
+ - db:reconcile converges an owned relation's self-grant
12
+
13
+ ## [0.4.72] - 2026-09-04
14
+
15
+ ### Fixed
16
+
17
+ - db:reconcile defers function-body validation (check_function_bodies=off)
18
+
7
19
  ## [0.4.71] - 2026-09-04
8
20
 
9
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.71",
3
+ "version": "0.4.73",
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>",
@@ -178,7 +178,7 @@
178
178
  "jest": "29.7.0",
179
179
  "react": "19.2.0",
180
180
  "ts-jest": "29.4.9",
181
- "@everystack/server": "0.4.29"
181
+ "@everystack/server": "0.4.30"
182
182
  },
183
183
  "scripts": {
184
184
  "test": "jest",
@@ -188,6 +188,16 @@ export async function executeReconcile(
188
188
  // as it was. See derivedSearchPath.
189
189
  const setSearchPath = renderSetSearchPath(derivedSearchPath(parsed.objects), atomic);
190
190
 
191
+ // Defer SQL/PLpgSQL function-body validation for the apply window — the pg_dump/pg_restore idiom
192
+ // db:build already uses (db-build.ts). A SQL-language function's body is validated at CREATE, so a
193
+ // function that reads a derived object being dropped-and-recreated in the SAME batch aborts with
194
+ // "relation does not exist" when it is (re)created before that object. The bodies are already
195
+ // validated upstream (db:build against a fresh DB, db:check in CI); the reconcile only needs the
196
+ // objects to EXIST by commit, which they do. Without this, reconcile fails where db:build succeeds
197
+ // on the very same schema — the only difference between them was this SET. LOCAL in the atomic
198
+ // batch (reverts at COMMIT); session-scoped on the unwrapped path (its connection is ephemeral).
199
+ const setCheckBodies = atomic ? 'SET LOCAL check_function_bodies = off' : 'SET check_function_bodies = off';
200
+
191
201
  // A schema populated only by derived objects has no model in it, so the state layer never
192
202
  // CREATE SCHEMAs it — the derived apply ensures its own object schemas (+ their USAGE
193
203
  // grants) before anything is created in them. Empty for all-public apps. See
@@ -221,9 +231,10 @@ export async function executeReconcile(
221
231
 
222
232
  if (rendered.statements.length > 0) {
223
233
  if (atomic) {
224
- // The SET rides the same simple-query batch (and the same transaction) as the DDL,
225
- // so bare body refs resolve to their real schema; SET LOCAL resets it at COMMIT.
226
- await runner([...ensureSchemas, setSearchPath, ...rendered.statements].filter(Boolean).join(';\n'));
234
+ // The SETs ride the same simple-query batch (and the same transaction) as the DDL, so bare
235
+ // body refs resolve to their real schema and function bodies defer validation; SET LOCAL
236
+ // resets both at COMMIT.
237
+ await runner([...ensureSchemas, setSearchPath, setCheckBodies, ...rendered.statements].filter(Boolean).join(';\n'));
227
238
  } else {
228
239
  // Self-committing statements (CONCURRENTLY, VACUUM) refuse ANY transaction block —
229
240
  // including the implicit one a joined multi-statement simple query runs in, so the
@@ -233,7 +244,17 @@ export async function executeReconcile(
233
244
  // create resolves its bare refs.
234
245
  for (const statement of ensureSchemas) await runner(statement);
235
246
  if (setSearchPath) await runner(setSearchPath);
236
- for (const statement of rendered.statements) await runner(statement);
247
+ // No transaction to scope the deferral to (this path exists for statements a transaction
248
+ // forbids), so the session GUC is set bare and RESET in a finally. A leak of THIS GUC is
249
+ // benign, unlike search_path: it only defers a body validation that already ran upstream
250
+ // (db:build against a fresh DB, db:check in CI) — it never changes what a query resolves to
251
+ // or what a fingerprint reads, so it cannot mint a wrong result the way a leaked path could.
252
+ await runner(setCheckBodies);
253
+ try {
254
+ for (const statement of rendered.statements) await runner(statement);
255
+ } finally {
256
+ await runner('RESET check_function_bodies');
257
+ }
237
258
  }
238
259
  }
239
260
 
@@ -106,24 +106,30 @@ export interface ObjectGrantDiff {
106
106
  }
107
107
 
108
108
  /**
109
- * Ownership context for a FUNCTION's grant diff. Absent for relations.
109
+ * Ownership context for an object's grant diff — passed for EVERY kind (functions AND relations).
110
110
  *
111
- * Owner-implicit EXECUTE is excluded from both grant reads (`grantee <> proowner`), which is
112
- * correct in isolation — an owner's privileges are not grants. But it means the SET of
113
- * non-owner grants is computed RELATIVE TO THE OWNER, so two sides with different owners
114
- * produce two different sets for the same function, and the differ reconciles a difference
115
- * that is purely ownership.
111
+ * Owner-implicit privileges are excluded from both grant reads (`grantee <> proowner` for
112
+ * functions, `<> relowner` for relations), which is correct in isolation — an owner's privileges
113
+ * are not grants. But it means the SET of non-owner grants is computed RELATIVE TO THE OWNER, so
114
+ * two sides with different owners produce two different sets for the same object, and the differ
115
+ * reconciles a difference that is purely ownership.
116
116
  *
117
- * Measured by a consumer, 2026-08-10: their local `resolve_user_slug` is owned by
117
+ * First measured on functions, 2026-08-10: a consumer's local `resolve_user_slug` is owned by
118
118
  * `outbound_migrator` (owner-implicit, so the pull rendered NO privilege), while on stage the
119
119
  * same function is owned by `slug_resolver` and `outbound_migrator` holds an EXPLICIT grant.
120
120
  * The differ planned `REVOKE EXECUTE ON api.resolve_user_slug FROM outbound_migrator` — a real
121
121
  * access removal minted from an ownership difference. They did not apply it.
122
+ *
123
+ * The relation half surfaced 2026-09-04: after `db:provision ADOPT` made `outbound_migrator` own a
124
+ * VIEW the model also lists in its grants, the live read excluded the owner's own rows, so the
125
+ * declared self-grant was minted on every reconcile and never converged. The relation call site
126
+ * used to pass no ownership context on the belief that a relation's grant set does not shift with
127
+ * its owner — true only while relacl is NULL. Both kinds now share this context.
122
128
  */
123
129
  export interface GrantOwnerContext {
124
130
  /** The owner the DECLARED grant set was computed against, when the models declare one. */
125
131
  declared?: string;
126
- /** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner)`). */
132
+ /** The owner the LIVE grant set was computed against (`pg_get_userbyid(proowner|relowner)`). */
127
133
  live?: string;
128
134
  }
129
135
 
@@ -131,8 +137,9 @@ export interface GrantOwnerContext {
131
137
  * The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
132
138
  * the table path's reconcileGrants, per derived object. Empty when they match.
133
139
  *
134
- * When `owners` is supplied (functions) and the two sides cannot be shown to have been
135
- * computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs still flow: a
140
+ * When `owners` is supplied (every kind — functions and relations) and the two sides cannot be
141
+ * shown to have been computed against the SAME owner, REVOKEs are WITHHELD and reported. GRANTs
142
+ * still flow: a
136
143
  * redundant GRANT is noise, a wrong REVOKE removes access that was really there. Failing to
137
144
  * revoke is the recoverable error and it is never silent.
138
145
  *
@@ -198,10 +205,10 @@ export function diffObjectGrants(
198
205
  out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
199
206
  } else {
200
207
  const why = grantee === owners!.live
201
- ? `'${grantee}' is the function's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
202
- : `'${grantee}' is the function's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
208
+ ? `'${grantee}' is the object's live OWNER, whose implicit privileges are excluded from both grant reads — so this difference is ownership, not a grant`
209
+ : `'${grantee}' is the object's declared owner (live owner: ${owners!.live}), and an owner's implicit privileges are excluded from both grant reads`;
203
210
  suppressed.push(
204
- `${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the function's owner to restore revokes.`,
211
+ `${target}: withheld REVOKE ${toRevoke.join(', ')} FROM ${grantee} — ${why}. An owner's implicit privileges are excluded from both reads, so the two grant sets are not comparable and this revoke may remove access that is really there. Declare the object's owner to restore revokes.`,
205
212
  );
206
213
  }
207
214
  }
@@ -482,11 +482,21 @@ export function planReconcile(
482
482
  );
483
483
  } else {
484
484
  const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
485
- // Functions carry ownership context; relations do not (relacl's NULL means owner-only
486
- // and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
487
- const owners = src.kind === 'function'
488
- ? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
489
- : undefined;
485
+ // Ownership context is passed for EVERY kind. An owner's implicit privileges are excluded
486
+ // from the live read for relations too (`a.grantee <> c.relowner`), so a model that both
487
+ // owns an object and lists that owner in its grants declares a self-grant the read can
488
+ // never confirm — diffObjectGrants normalizes the owner grantee away. The earlier belief
489
+ // that a relation's grant set "does not shift with its owner" holds only while relacl is
490
+ // NULL; the first non-owner grant materializes the ACL and the owner's own rows drop out,
491
+ // exactly as for a function. (A consumer's ADOPT'd view, owned by a role its model also
492
+ // grants, looped forever — 2026-09-04.)
493
+ // KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
494
+ // is a failure to ADD access (availability), never to remove it, and unnormalized code did
495
+ // not converge on it either.
496
+ const owners = {
497
+ ...(src.owner !== undefined ? { declared: src.owner } : {}),
498
+ ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
499
+ };
490
500
  const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
491
501
  if (statements.length > 0) regrants.push({ identity: src.identity, statements });
492
502
  // Never silent: a withheld revoke is reported every run until the owner is declared.