@everystack/cli 0.4.17 → 0.4.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
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>",
@@ -42,6 +42,7 @@ import {
42
42
  renderSchemaLogInsert,
43
43
  derivedSearchPath,
44
44
  renderSetSearchPath,
45
+ renderEnsureObjectSchemas,
45
46
  ENSURE_RECONCILER_SQL,
46
47
  } from '../derived-apply.js';
47
48
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
@@ -151,6 +152,12 @@ export async function executeReconcile(
151
152
  // as it was. See derivedSearchPath.
152
153
  const setSearchPath = renderSetSearchPath(derivedSearchPath(parsed.objects), atomic);
153
154
 
155
+ // A schema populated only by derived objects has no model in it, so the state layer never
156
+ // CREATE SCHEMAs it — the derived apply ensures its own object schemas (+ their USAGE
157
+ // grants) before anything is created in them. Empty for all-public apps. See
158
+ // renderEnsureObjectSchemas.
159
+ const ensureSchemas = renderEnsureObjectSchemas(parsed.objects);
160
+
154
161
  if (atomic) await runner('BEGIN');
155
162
  try {
156
163
  // The session guard: postgres-js `max: 1` is a pool of one, not a session lease — a
@@ -166,7 +173,7 @@ export async function executeReconcile(
166
173
  if (atomic) {
167
174
  // The SET rides the same simple-query batch (and the same transaction) as the DDL,
168
175
  // so bare body refs resolve to their real schema; SET LOCAL resets it at COMMIT.
169
- await runner([setSearchPath, ...rendered.statements].filter(Boolean).join(';\n'));
176
+ await runner([...ensureSchemas, setSearchPath, ...rendered.statements].filter(Boolean).join(';\n'));
170
177
  } else {
171
178
  // Self-committing statements (CONCURRENTLY, VACUUM) refuse ANY transaction block —
172
179
  // including the implicit one a joined multi-statement simple query runs in, so the
@@ -174,6 +181,7 @@ export async function executeReconcile(
174
181
  // earlier statements committed: the documented cost of the unwrapped path; the next
175
182
  // plan re-converges from what actually landed. The session SET precedes them so every
176
183
  // create resolves its bare refs.
184
+ for (const statement of ensureSchemas) await runner(statement);
177
185
  if (setSearchPath) await runner(setSearchPath);
178
186
  for (const statement of rendered.statements) await runner(statement);
179
187
  }
@@ -211,7 +219,7 @@ export async function executeReconcile(
211
219
  bookkeeping.push(renderSchemaLogInsert({
212
220
  kind: 'compute reconcile',
213
221
  sql: rendered.statements.length > 0
214
- ? [setSearchPath, ...rendered.statements].filter(Boolean).join(';\n')
222
+ ? [...ensureSchemas, setSearchPath, ...rendered.statements].filter(Boolean).join(';\n')
215
223
  : '-- provenance-only (baseline/rebaseline/prune)',
216
224
  outcome: 'applied',
217
225
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { normalizeSql, parseQualified, type SourceObject } from './derived-source.js';
17
17
  import type { ReconcilePlan } from './derived-plan.js';
18
+ import { parseGrantAttachments } from './derived-grants.js';
18
19
  import { quoteIdent, quoteQualified } from './pg-ident.js';
19
20
 
20
21
  /** SQL string literal with '' doubling (standard_conforming_strings). */
@@ -136,6 +137,43 @@ export function renderSetSearchPath(schemas: string[], local: boolean): string |
136
137
  return `SET ${local ? 'LOCAL ' : ''}search_path = ${schemas.map(quoteIdent).join(', ')}`;
137
138
  }
138
139
 
140
+ /**
141
+ * `CREATE SCHEMA IF NOT EXISTS` for each non-public schema the declared objects LIVE
142
+ * in — migration-compile's rule 0a mirrored into the derived layer. Rule 0a only
143
+ * creates schemas a MODEL lives in, so a schema populated purely by derived objects
144
+ * (the derivation half of a stats/stat_views split) was never created and a
145
+ * from-scratch apply died on its first CREATE. Object schemas only, never
146
+ * declaredDeps schemas: a dependency's schema belongs to the models that own it, and
147
+ * a missing one must still fail at CREATE instead of being silently minted empty.
148
+ *
149
+ * `GRANT USAGE` rides along per schema (0a-bis mirrored), derived from the objects'
150
+ * own grant attachments: a role granted SELECT on a matview in a non-public schema
151
+ * cannot reach it without USAGE, so the object grant is dead without this. A PUBLIC
152
+ * that appears only as explicitly-revoked gets nothing. Empty for all-public apps —
153
+ * their apply stays byte-identical. Idempotent when the state layer already created
154
+ * the schema (a model schema also carrying derived objects).
155
+ */
156
+ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
157
+ const statements: string[] = [];
158
+ const schemas = [...new Set(objects.map((o) => o.schema))].filter((s) => s !== 'public').sort();
159
+ for (const schema of schemas) {
160
+ statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
161
+ const roles = new Set<string>();
162
+ for (const o of objects) {
163
+ if (o.schema !== schema) continue;
164
+ const { grants } = parseGrantAttachments(o.attachments);
165
+ for (const [role, privileges] of Object.entries(grants)) {
166
+ if (privileges.length > 0) roles.add(role);
167
+ }
168
+ }
169
+ if (roles.size > 0) {
170
+ const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
171
+ statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${targets}`);
172
+ }
173
+ }
174
+ return statements;
175
+ }
176
+
139
177
  export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
140
178
  const srcById = new Map(source.map((o) => [o.identity, o]));
141
179
  const statements: string[] = [];
@@ -107,7 +107,7 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
107
107
  try { await runner(`DROP SCHEMA IF EXISTS "${plan.incoming}" CASCADE`); } catch { /* best effort */ }
108
108
  return {
109
109
  status: 'refused-integrity',
110
- reason: `the swap transaction failed usually the cross-schema FK re-validation (an app row references a ${opts.schema} key the artifact dropped): ${String(err?.message ?? err)}`,
110
+ reason: `the swap transaction failed and rolled back whole: ${String(err?.message ?? err)}. Candidate causes: a cross-schema FK re-validating against the new data (an app row references a ${opts.schema} key the artifact dropped), or an authz statement colliding with authz the artifact already carries — the message above names the actual failing statement.`,
111
111
  };
112
112
  }
113
113