@everystack/cli 0.4.102 → 0.4.104
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 +62 -0
- package/package.json +3 -3
- package/src/cli/authz-compile.ts +48 -11
- package/src/cli/authz-contract.ts +6 -0
- package/src/cli/authz-derive.ts +3 -1
- package/src/cli/commands/db-apply.ts +34 -10
- package/src/cli/commands/db-backup.ts +62 -26
- package/src/cli/commands/db-fork.ts +2 -1
- package/src/cli/commands/db-plan.ts +5 -0
- package/src/cli/commands/db-pull.ts +48 -12
- package/src/cli/commands/db-reconcile.ts +34 -5
- package/src/cli/commands/db-sync.ts +32 -3
- package/src/cli/commands/db.ts +46 -21
- package/src/cli/db-build.ts +11 -1
- package/src/cli/declared-diff.ts +3 -2
- package/src/cli/derived-apply.ts +5 -1
- package/src/cli/derived-catchup.ts +4 -0
- package/src/cli/derived-compile.ts +2 -1
- package/src/cli/derived-introspect.ts +22 -1
- package/src/cli/derived-plan.ts +64 -0
- package/src/cli/derived-render.ts +1 -0
- package/src/cli/edge-plan.ts +35 -0
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/lifecycle-canon.ts +2 -1
- package/src/cli/lifecycle-derived.ts +334 -45
- package/src/cli/lifecycle-preflight.ts +498 -0
- package/src/cli/lifecycle-pull.ts +119 -11
- package/src/cli/migration-compile.ts +4 -3
- package/src/cli/migration-generate.ts +3 -2
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-render.ts +80 -8
- package/src/cli/plain-output.ts +14 -1
- package/src/cli/schema-compile.ts +34 -67
- package/src/cli/schema-fingerprint.ts +3 -2
- package/src/cli/schema-introspect.ts +33 -2
- package/src/cli/state-function-deps.ts +2 -1
- package/src/cli/lifecycle-verify.ts +0 -232
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/** Read-only prerequisite for the state and derived halves of a lifecycle apply. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
|
|
4
|
+
import type { QueryRunner } from './authz-contract';
|
|
5
|
+
import { compileTableContract } from './authz-compile';
|
|
6
|
+
import { parseQualified, type SourceObject } from './derived-source';
|
|
7
|
+
import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
|
|
8
|
+
|
|
9
|
+
export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
|
|
10
|
+
export const LIFECYCLE_LOG_OUTDATED_REFUSAL = 'everystack.destroy_log is out of date (it has no before column or no marker functions); run db:provision before db:apply';
|
|
11
|
+
/**
|
|
12
|
+
* Present, and current: it has the `before` column and the three marker functions the generated
|
|
13
|
+
* destroy calls (destroy_log_mark, destroy_log_unmark, destroy_cutoff). The reconcile creates
|
|
14
|
+
* functions with check_function_bodies off, so on an earlier log it would install them and every
|
|
15
|
+
* lifecycle DELETE would then fail with an unknown-column or unknown-function error.
|
|
16
|
+
*/
|
|
17
|
+
export const LIFECYCLE_LOG_SQL = `SELECT pg_catalog.to_regclass('everystack.destroy_log') IS NOT NULL AS present,
|
|
18
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_attribute WHERE attrelid = pg_catalog.to_regclass('everystack.destroy_log')
|
|
19
|
+
AND attname = 'before' AND NOT attisdropped)
|
|
20
|
+
AND pg_catalog.to_regprocedure('everystack.destroy_log_mark(timestamptz)') IS NOT NULL
|
|
21
|
+
AND pg_catalog.to_regprocedure('everystack.destroy_log_unmark()') IS NOT NULL
|
|
22
|
+
AND pg_catalog.to_regprocedure('everystack.destroy_cutoff()') IS NOT NULL AS current`;
|
|
23
|
+
|
|
24
|
+
/** The refusal for one LIFECYCLE_LOG_SQL row, or null when the log is present and current. */
|
|
25
|
+
export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unknown } | undefined): string | null {
|
|
26
|
+
if (log?.present !== true) return LIFECYCLE_LOG_REFUSAL;
|
|
27
|
+
return log.current === true ? null : LIFECYCLE_LOG_OUTDATED_REFUSAL;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
|
|
32
|
+
* rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
|
|
33
|
+
*/
|
|
34
|
+
export async function lifecycleApplyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
|
|
35
|
+
if (!models.some(model => model.lifecycle === true)) return null;
|
|
36
|
+
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
37
|
+
// Before the plan: a modeled table's keys are the plan's to converge (the models declare each
|
|
38
|
+
// one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
|
|
39
|
+
const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
|
|
40
|
+
return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models), modeledTables });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
44
|
+
const ident = (s: string): string => (/^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`);
|
|
45
|
+
/** A `schema.table` label, quoted for a fix statement. */
|
|
46
|
+
const quoteLabel = (label: string): string => {
|
|
47
|
+
const dot = label.indexOf('.');
|
|
48
|
+
return `${ident(label.slice(0, dot))}.${ident(label.slice(dot + 1))}`;
|
|
49
|
+
};
|
|
50
|
+
const textArray = (values: readonly string[]): string => `ARRAY[${values.map(literal).join(', ')}]::text[]`;
|
|
51
|
+
/** An aclexplode grantee by name, PUBLIC for 0. */
|
|
52
|
+
const granteeName = (column: string): string =>
|
|
53
|
+
`CASE WHEN ${column} = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(${column})::text END`;
|
|
54
|
+
const md5 = (text: string): string => createHash('md5').update(text, 'utf8').digest('hex');
|
|
55
|
+
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
56
|
+
const textList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v.startsWith('{')
|
|
57
|
+
? v.slice(1, -1).split(',').filter(Boolean).map(s => s.replace(/^"|"$/g, '')) : []);
|
|
58
|
+
const MARKERS = Object.keys(DESTROY_LOG_MARKER_BODIES) as Array<keyof typeof DESTROY_LOG_MARKER_BODIES>;
|
|
59
|
+
const MARKER_CONFIG = `search_path=${DESTROY_LOG_MARKER_SEARCH_PATH}`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the catalog check reads from the declared side. The check needs nothing else from the
|
|
63
|
+
* models, so db:reconcile's ops action can run it from its wire payload, which carries no models.
|
|
64
|
+
*
|
|
65
|
+
* lifecycleTables `schema.table` of every lifecycle table
|
|
66
|
+
* applicationRoles every role the models' compiled contracts grant to or name in a policy. A
|
|
67
|
+
* request runs as one of them. SQL cannot tell which role is the application's
|
|
68
|
+
* login; a login with no grant or policy of its own is application-side only
|
|
69
|
+
* when it can assume one of these. PUBLIC and the lifecycle role never count.
|
|
70
|
+
*/
|
|
71
|
+
export interface LifecycleCatalogInputs {
|
|
72
|
+
lifecycleTables: readonly string[];
|
|
73
|
+
applicationRoles: readonly string[];
|
|
74
|
+
/** Every declared dependent key (dependentKeysFromDeclared). */
|
|
75
|
+
dependentKeys: readonly LifecycleDependentKey[];
|
|
76
|
+
/**
|
|
77
|
+
* `schema.table` of each lifecycle table whose manifest has no nullify record (an older CLI sent
|
|
78
|
+
* it): a key it declares 'nullify' cannot be seen, so its finding says to upgrade the CLI.
|
|
79
|
+
*/
|
|
80
|
+
nullifyUnrecorded?: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Before the plan only (lifecycleApplyRefusal): `schema.table` of every modeled table, whose keys
|
|
83
|
+
* rule 6 leaves to the plan. Absent after the state apply: every key is checked.
|
|
84
|
+
*/
|
|
85
|
+
modeledTables?: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
|
|
89
|
+
export interface LifecycleDependentKey {
|
|
90
|
+
child: string;
|
|
91
|
+
column: string;
|
|
92
|
+
parent: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The declared dependents ('cascade', 'restrict' and 'nullify'), read from the generated lifecycle
|
|
97
|
+
* functions' manifests: the declared layer itself, so the models path and a db:reconcile payload
|
|
98
|
+
* (which carries no models) read one source.
|
|
99
|
+
*/
|
|
100
|
+
export function dependentKeysFromDeclared(declared: readonly Parameters<typeof lifecycleManifestOf>[0][]): LifecycleDependentKey[] {
|
|
101
|
+
const label = (name: string): string => {
|
|
102
|
+
if (!name.startsWith('"')) return name;
|
|
103
|
+
const { schema, name: table } = parseQualified(name);
|
|
104
|
+
return `${schema}.${table}`;
|
|
105
|
+
};
|
|
106
|
+
const keys = new Map<string, LifecycleDependentKey>();
|
|
107
|
+
for (const object of declared) {
|
|
108
|
+
const manifest = lifecycleManifestOf(object);
|
|
109
|
+
if (!manifest) continue;
|
|
110
|
+
const edges = [...manifest.decl.edges, ...(manifest.decl.nullify ?? []).map(n => ({ ...n, parent: manifest.model }))];
|
|
111
|
+
for (const edge of edges) {
|
|
112
|
+
const key = { child: label(edge.child), column: edge.column, parent: edge.parent };
|
|
113
|
+
keys.set(JSON.stringify(key), key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...keys.values()].sort((a, b) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The lifecycle tables whose transition manifest has no `decl.nullify` (a CLI that predates it sent the layer). */
|
|
120
|
+
export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<typeof lifecycleManifestOf>[0][]): string[] {
|
|
121
|
+
return [...new Set(declared.map(object => lifecycleManifestOf(object))
|
|
122
|
+
.filter(manifest => manifest?.role === 'transition' && manifest.decl.nullify === undefined)
|
|
123
|
+
.map(manifest => manifest!.model))].sort();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
|
|
127
|
+
|
|
128
|
+
/** The catalog check's inputs, read from the models. */
|
|
129
|
+
export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[] } {
|
|
130
|
+
const roles = new Set<string>();
|
|
131
|
+
for (const model of models) {
|
|
132
|
+
const contract = compileTableContract(model);
|
|
133
|
+
for (const role of Object.keys(contract.grants)) roles.add(role);
|
|
134
|
+
for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
|
|
135
|
+
for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
|
|
139
|
+
applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
|
|
140
|
+
dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `x` can act as `y`: SET ROLE to it, or inherit its privileges. On PostgreSQL 16 and later
|
|
146
|
+
* pg_has_role's MEMBER also counts an ADMIN-only grant (no SET, no INHERIT), which a non-superuser
|
|
147
|
+
* CREATEROLE role gets on every role it creates; SET and USAGE do not. Before 16 every membership
|
|
148
|
+
* could SET ROLE, MEMBER means exactly that, and 'SET' is not a keyword, so the branch is on
|
|
149
|
+
* server_version_num.
|
|
150
|
+
*/
|
|
151
|
+
const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
|
|
152
|
+
WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
|
|
153
|
+
ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
|
|
154
|
+
/** `x` can act as `y`, or hold ADMIN on it: an ADMIN holder can grant `y` to itself WITH SET. */
|
|
155
|
+
const reaches = (x: string, y: string): string =>
|
|
156
|
+
`(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
|
|
160
|
+
* as, an application role. A superuser passes every pg_has_role and is left out; so is the RDS
|
|
161
|
+
* master, whose ADMIN-only membership in the roles it created lets it act as none of them.
|
|
162
|
+
*/
|
|
163
|
+
const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::text AS rolname FROM pg_catalog.pg_roles r
|
|
164
|
+
WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
|
|
165
|
+
WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
|
|
166
|
+
|
|
167
|
+
/** Rule 1, attributes. */
|
|
168
|
+
const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
|
|
169
|
+
COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
|
|
170
|
+
COALESCE(lc.rolcreaterole, false) AS createrole, COALESCE(lc.rolbypassrls, false) AS bypassrls
|
|
171
|
+
FROM (SELECT 1) AS one LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}`;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Rule 1, members: each application-side role that reaches the lifecycle role, with the
|
|
175
|
+
* application roles it acts as. The operator's membership (WITH INHERIT FALSE, SET TRUE, which a
|
|
176
|
+
* non-superuser builder needs to create the lifecycle functions) passes: it acts as no application role.
|
|
177
|
+
*/
|
|
178
|
+
const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
|
|
179
|
+
SELECT s.rolname AS role, ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
|
|
180
|
+
WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = s.oid OR ${assumes('s.oid', 'a.oid')}) ORDER BY 1) AS apps
|
|
181
|
+
FROM side s JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
|
|
182
|
+
WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
|
|
183
|
+
ORDER BY 1`;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
|
|
187
|
+
* write the marker; the owner of a marker function can replace it. Each such object is a finding
|
|
188
|
+
* when its owner is the lifecycle role, or when an application-side role is that owner, can act
|
|
189
|
+
* as it, or holds ADMIN on it. The operator owning all of them is the documented setup and passes.
|
|
190
|
+
*/
|
|
191
|
+
const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
|
|
192
|
+
owned AS (
|
|
193
|
+
SELECT 'table' AS kind, n.nspname::text || '.' || c.relname::text AS name, c.relowner AS owner
|
|
194
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
195
|
+
WHERE c.relkind IN ('r', 'p') AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
|
|
196
|
+
UNION ALL
|
|
197
|
+
SELECT 'table', 'everystack.destroy_log', c.relowner FROM pg_catalog.pg_class c
|
|
198
|
+
WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
|
|
199
|
+
UNION ALL
|
|
200
|
+
SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
|
|
201
|
+
JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig))
|
|
202
|
+
SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
|
|
203
|
+
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
|
|
204
|
+
FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
|
|
205
|
+
WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
|
|
206
|
+
ORDER BY 2, 1`;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
|
|
210
|
+
* columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
|
|
211
|
+
* (table or column) held by anyone but its owner: a writer could forge or remove a marker.
|
|
212
|
+
*/
|
|
213
|
+
const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
|
|
214
|
+
a.privilege_type AS privilege, NULL::text AS "column"
|
|
215
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
216
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
|
|
217
|
+
WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
|
|
218
|
+
((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
|
|
219
|
+
OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
|
|
220
|
+
UNION ALL
|
|
221
|
+
SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
|
|
222
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
223
|
+
JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
|
|
224
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
|
|
225
|
+
WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
|
|
226
|
+
((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
|
|
227
|
+
OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
|
|
228
|
+
ORDER BY 1, 4 NULLS FIRST, 3, 2`;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
|
|
232
|
+
* lifecycle role and nobody else but the owner (PUBLIC included; a NULL ACL is PUBLIC's default),
|
|
233
|
+
* and the language, settings and body (md5 of prosrc) provisioning installs.
|
|
234
|
+
*/
|
|
235
|
+
const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS present, COALESCE(p.prosecdef, false) AS secdef,
|
|
236
|
+
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(d.relowner)::text AS log_owner,
|
|
237
|
+
pg_catalog.md5(p.prosrc) AS body_md5, l.lanname::text AS language,
|
|
238
|
+
pg_catalog.array_to_string(p.proconfig, '; ') AS config,
|
|
239
|
+
ARRAY(SELECT DISTINCT ${granteeName('a.grantee')} FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
|
|
240
|
+
WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner AND a.grantee IS DISTINCT FROM lc.oid ORDER BY 1) AS others,
|
|
241
|
+
EXISTS (SELECT 1 FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
|
|
242
|
+
WHERE a.privilege_type = 'EXECUTE' AND a.grantee = lc.oid) AS lifecycle_execute
|
|
243
|
+
FROM (VALUES ${MARKERS.map((sig, i) => `(${i}, ${literal(sig)})`).join(', ')}) AS s(i, sig)
|
|
244
|
+
LEFT JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(s.sig)
|
|
245
|
+
LEFT JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
246
|
+
LEFT JOIN pg_catalog.pg_class d ON d.oid = pg_catalog.to_regclass('everystack.destroy_log')
|
|
247
|
+
LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
|
|
248
|
+
ORDER BY s.i`;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Rule 5. A trigger on a lifecycle table whose function is SECURITY DEFINER and owned by neither
|
|
252
|
+
* the table's owner nor the lifecycle role: it runs as that third role inside every delete.
|
|
253
|
+
*/
|
|
254
|
+
const definerTriggerSql = (tables: string[], lc: string): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
|
|
255
|
+
pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
|
|
256
|
+
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(c.relowner)::text AS table_owner
|
|
257
|
+
FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
|
|
258
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
|
|
259
|
+
JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
|
|
260
|
+
WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
|
|
261
|
+
AND p.prosecdef AND p.proowner <> c.relowner
|
|
262
|
+
AND p.proowner IS DISTINCT FROM (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(lc)})
|
|
263
|
+
ORDER BY 1, 2`;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
|
|
267
|
+
* in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
|
|
268
|
+
*/
|
|
269
|
+
const inboundKeySql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS child, k.conname::text AS "constraint",
|
|
270
|
+
ARRAY(SELECT a.attname::text FROM pg_catalog.unnest(k.conkey) WITH ORDINALITY AS u(attnum, i)
|
|
271
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.attnum ORDER BY u.i) AS columns,
|
|
272
|
+
pn.nspname::text || '.' || p.relname::text AS parent, k.confdeltype::text AS action
|
|
273
|
+
FROM pg_catalog.pg_constraint k JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
|
|
274
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
275
|
+
JOIN pg_catalog.pg_class p ON p.oid = k.confrelid JOIN pg_catalog.pg_namespace pn ON pn.oid = p.relnamespace
|
|
276
|
+
WHERE k.contype = 'f' AND k.conparentid = 0 AND (pn.nspname::text || '.' || p.relname::text) = ANY(${textArray(tables)})
|
|
277
|
+
ORDER BY 1, 2`;
|
|
278
|
+
|
|
279
|
+
const ON_DELETE: Record<string, string> = { a: 'NO ACTION', r: 'RESTRICT', c: 'CASCADE', n: 'SET NULL', d: 'SET DEFAULT' };
|
|
280
|
+
|
|
281
|
+
const OWNER_HAZARD = {
|
|
282
|
+
table: 'an owner can disable its triggers',
|
|
283
|
+
log: 'an owner can write the marker',
|
|
284
|
+
function: 'an owner can replace the function',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The live catalog facts a lifecycle soft delete rests on, which the generator cannot guarantee.
|
|
289
|
+
* Every finding is named on its own line with its fix, or null when there is none.
|
|
290
|
+
* `lifecycleRole` is a test seam (the role is cluster-level); callers leave it unset.
|
|
291
|
+
*
|
|
292
|
+
* THREAT MODEL. This defends an HONEST operator from installing or re-arming the lifecycle
|
|
293
|
+
* SECURITY DEFINER objects onto a database whose grants or ownership have drifted: an application
|
|
294
|
+
* role that owns a lifecycle table, holds TRIGGER on one, can write everystack.destroy_log, a
|
|
295
|
+
* replaced marker function, and the rest below. It does not defend against a hostile IAM deployer,
|
|
296
|
+
* who can already reach the ops Lambda's other actions; so the roles may come from the caller
|
|
297
|
+
* (db:reconcile's wire payload carries them).
|
|
298
|
+
*
|
|
299
|
+
* The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
|
|
300
|
+
* (inputs from the models); db:reconcile, local and in the ops Lambda, through
|
|
301
|
+
* lifecycleReconcileRefusal (tables from the declared transition triggers).
|
|
302
|
+
*/
|
|
303
|
+
export async function lifecycleCatalogRefusal(
|
|
304
|
+
runner: QueryRunner,
|
|
305
|
+
inputs: LifecycleCatalogInputs,
|
|
306
|
+
options: { lifecycleRole?: string } = {},
|
|
307
|
+
): Promise<string | null> {
|
|
308
|
+
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
309
|
+
const tables = [...new Set(inputs.lifecycleTables)].sort();
|
|
310
|
+
if (tables.length === 0) return null;
|
|
311
|
+
const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
|
|
312
|
+
const findings: string[] = [];
|
|
313
|
+
|
|
314
|
+
const [role] = await runner(roleSql(lc));
|
|
315
|
+
if (!truthy(role?.present)) {
|
|
316
|
+
findings.push(`role ${lc} is missing. Fix: run db:provision.`);
|
|
317
|
+
} else {
|
|
318
|
+
const attributes = (['login', 'super', 'createrole', 'bypassrls'] as const).filter(key => truthy(role[key]));
|
|
319
|
+
const word = { login: 'LOGIN', super: 'SUPERUSER', createrole: 'CREATEROLE', bypassrls: 'BYPASSRLS' };
|
|
320
|
+
if (attributes.length > 0) {
|
|
321
|
+
findings.push(`role ${lc} has ${attributes.map(key => word[key]).join(', ')}. `
|
|
322
|
+
+ `Fix: ALTER ROLE ${ident(lc)} ${attributes.map(key => `NO${word[key]}`).join(' ')};`);
|
|
323
|
+
}
|
|
324
|
+
for (const member of await runner(memberSql(apps, lc))) {
|
|
325
|
+
findings.push(`role ${member.role} can assume ${lc} and is, or can assume, an application role (${textList(member.apps).join(', ')}). `
|
|
326
|
+
+ `Fix: REVOKE ${ident(lc)} FROM ${ident(String(member.role))}; (or from the role it reaches ${lc} through).`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const row of await runner(ownerSql(tables, apps, lc))) {
|
|
331
|
+
const kind = row.kind === 'function' ? 'function' : 'table';
|
|
332
|
+
const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log : OWNER_HAZARD[kind];
|
|
333
|
+
const via = textList(row.via);
|
|
334
|
+
const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
|
|
335
|
+
: via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
|
|
336
|
+
: `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
|
|
337
|
+
const name = kind === 'function' ? String(row.name) : quoteLabel(String(row.name));
|
|
338
|
+
const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
|
|
339
|
+
? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
|
|
340
|
+
: `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
|
|
341
|
+
findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const row of await runner(aclSql(tables))) {
|
|
345
|
+
const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
|
|
346
|
+
const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
|
|
347
|
+
findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. `
|
|
348
|
+
+ `Fix: REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const row of await runner(markerSql(lc))) {
|
|
352
|
+
const sig = String(row.sig) as keyof typeof DESTROY_LOG_MARKER_BODIES;
|
|
353
|
+
const fn = `marker function ${sig}`;
|
|
354
|
+
if (!truthy(row.present)) { findings.push(`${fn} is missing. Fix: run db:provision.`); continue; }
|
|
355
|
+
if (!truthy(row.secdef)) findings.push(`${fn} is not SECURITY DEFINER. Fix: run db:provision.`);
|
|
356
|
+
if (row.owner !== row.log_owner) {
|
|
357
|
+
findings.push(`${fn} is owned by ${row.owner}, not ${row.log_owner} (the owner of everystack.destroy_log). `
|
|
358
|
+
+ `Fix: ALTER FUNCTION ${sig} OWNER TO ${ident(String(row.log_owner))}; then run db:provision.`);
|
|
359
|
+
}
|
|
360
|
+
const others = textList(row.others);
|
|
361
|
+
if (others.length > 0) findings.push(`${fn} grants EXECUTE to ${others.join(', ')}. Fix: run db:provision.`);
|
|
362
|
+
if (!truthy(row.lifecycle_execute)) findings.push(`${fn} does not grant EXECUTE to ${lc}. Fix: run db:provision.`);
|
|
363
|
+
if (row.language !== DESTROY_LOG_MARKER_LANGUAGES[sig]) {
|
|
364
|
+
findings.push(`${fn} is in language ${row.language}, not ${DESTROY_LOG_MARKER_LANGUAGES[sig]}. Fix: run db:provision.`);
|
|
365
|
+
}
|
|
366
|
+
if ((row.config ?? '') !== MARKER_CONFIG) {
|
|
367
|
+
findings.push(`${fn} has settings ${row.config || '(none)'}, not ${MARKER_CONFIG}. Fix: run db:provision.`);
|
|
368
|
+
}
|
|
369
|
+
if (row.body_md5 !== md5(DESTROY_LOG_MARKER_BODIES[sig])) {
|
|
370
|
+
findings.push(`${fn} has a body other than the one db:provision installs. Fix: run db:provision.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const row of await runner(definerTriggerSql(tables, lc))) {
|
|
375
|
+
findings.push(`trigger ${row.trigger} on ${row.table} runs ${row.function}, SECURITY DEFINER and owned by ${row.owner}, `
|
|
376
|
+
+ `neither the table's owner (${row.table_owner}) nor ${lc}; it runs as ${row.owner} inside every delete. `
|
|
377
|
+
+ `Fix: ALTER FUNCTION ${row.function} SECURITY INVOKER; or ALTER FUNCTION ${row.function} OWNER TO ${ident(String(row.table_owner))};`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
|
|
381
|
+
const modeled = new Set(inputs.modeledTables ?? []);
|
|
382
|
+
// After the state apply a key the models no longer hold, or now declare, may not be converged yet.
|
|
383
|
+
const applyFirst = inputs.modeledTables ? '' : 'If the models already refer to it by value or declare it, mint a plan that drops or rewrites the key '
|
|
384
|
+
+ '(db:plan, with --allow-drops when it is a drop) and db:apply it. Otherwise ';
|
|
385
|
+
const unrecorded = new Set(inputs.nullifyUnrecorded ?? []);
|
|
386
|
+
for (const row of await runner(inboundKeySql(tables))) {
|
|
387
|
+
// Before the plan, a modeled table's keys are the plan's to converge: the models hold only
|
|
388
|
+
// declared keys from lifecycle tables (or refuse to compile), and the plan drops the rest.
|
|
389
|
+
if (modeled.has(String(row.child))) continue;
|
|
390
|
+
const columns = textList(row.columns);
|
|
391
|
+
const key = `foreign key ${row.constraint} on ${row.child} (${columns.join(', ')}) references lifecycle table ${row.parent} `
|
|
392
|
+
+ `with ON DELETE ${ON_DELETE[String(row.action)] ?? row.action}`;
|
|
393
|
+
const drop = `ALTER TABLE ${quoteLabel(String(row.child))} DROP CONSTRAINT ${ident(String(row.constraint))};`;
|
|
394
|
+
if (!tables.includes(String(row.child))) {
|
|
395
|
+
findings.push(`${key}, but ${row.child} is not a lifecycle table: only a lifecycle table may hold a foreign key into a lifecycle table. `
|
|
396
|
+
+ `Fix: ${applyFirst}${applyFirst ? 'make' : 'Make'} ${row.child} a lifecycle model and declare the dependent on ${row.parent}, or refer to it by value and drop the key: ${drop}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (columns.length === 1 && declared.has(JSON.stringify([row.child, columns[0], row.parent]))) continue;
|
|
400
|
+
findings.push(`${key}, and no model declares what deleting a ${row.parent} row does to it: soft delete never consults it, `
|
|
401
|
+
+ `so destroy would defer or skip the row. Fix: ${applyFirst}${applyFirst ? 'declare' : 'Declare'} it as a hasMany dependent on ${row.parent}, or drop the key: ${drop}`
|
|
402
|
+
+ (unrecorded.has(String(row.parent)) ? ` The declared layer for ${row.parent} carries no nullify record, so the sending CLI may predate it: `
|
|
403
|
+
+ 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (findings.length === 0) return null;
|
|
407
|
+
return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
|
|
408
|
+
+ `fix each, then re-run:\n${findings.map(finding => ` - ${finding}`).join('\n')}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */
|
|
412
|
+
export const LIFECYCLE_ROLES_ABSENT_REFUSAL = 'this db:reconcile payload carries lifecycle-owned objects but no application roles, '
|
|
413
|
+
+ 'so the lifecycle catalog check cannot run. The sending CLI predates the check: upgrade the CLI (@everystack/cli) and re-run.';
|
|
414
|
+
|
|
415
|
+
/** The declared dependents are read from the transition functions' manifests: one without fails closed. */
|
|
416
|
+
export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]): string => `this db:reconcile payload's lifecycle transition `
|
|
417
|
+
+ `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
|
|
418
|
+
+ 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
|
|
419
|
+
|
|
420
|
+
/** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
|
|
421
|
+
const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The lifecycle tables, read from the declared derived layer: each `<t>_lifecycle_transition`
|
|
425
|
+
* trigger names its table. Every lifecycle-owned object must belong to one of those tables by its
|
|
426
|
+
* exact generated name in the table's schema (lifecycleRole, or a retired role). Never by prefix:
|
|
427
|
+
* every name of a table `t_lifecycle_x` starts with `t_lifecycle_`, so a prefix would hand them to
|
|
428
|
+
* `t`. One that belongs to none is a contradiction, refused: the check would otherwise run on fewer
|
|
429
|
+
* tables than the objects it arms. Never read from the live catalog, which would be a second
|
|
430
|
+
* derivation that drifts.
|
|
431
|
+
*/
|
|
432
|
+
export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]): { tables: string[] } | { refusal: string } {
|
|
433
|
+
const tables = [...new Set(declared.flatMap(object => {
|
|
434
|
+
if (object.kind !== 'trigger' || !object.table) return [];
|
|
435
|
+
const { schema, name } = parseQualified(object.table);
|
|
436
|
+
return object.name === `${name}_lifecycle_transition` ? [`${schema}.${name}`] : [];
|
|
437
|
+
}))].sort();
|
|
438
|
+
const orphans = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE
|
|
439
|
+
&& !tables.some(table => table.startsWith(`${object.schema}.`) && (lifecycleRole(`${object.schema}.${object.name}`, table) !== undefined
|
|
440
|
+
|| RETIRED_LIFECYCLE_ROLES.some(role => `${object.schema}.${object.name}` === `${table}_lifecycle_${role}`))))
|
|
441
|
+
.map(object => object.identity).sort();
|
|
442
|
+
if (orphans.length === 0) return { tables };
|
|
443
|
+
return {
|
|
444
|
+
refusal: `the declared derived layer contradicts itself: ${orphans.join(', ')} ${orphans.length === 1 ? 'is' : 'are'} owned by `
|
|
445
|
+
+ `${LIFECYCLE_OWNER_ROLE}, but no declared <table>_lifecycle_transition trigger names ${orphans.length === 1 ? 'its table' : 'their tables'}. `
|
|
446
|
+
+ 'The lifecycle tables are read from those triggers, so the catalog check cannot cover these objects. '
|
|
447
|
+
+ 'Compile the declared layer from the models with this CLI and re-run.',
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
|
|
453
|
+
* declared layer's own consistency, the application roles' presence (fail closed), then the
|
|
454
|
+
* catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles` is `unknown`
|
|
455
|
+
* because the ops Lambda reads it off the wire.
|
|
456
|
+
*/
|
|
457
|
+
export async function lifecycleReconcileRefusal(
|
|
458
|
+
runner: QueryRunner,
|
|
459
|
+
declared: readonly SourceObject[],
|
|
460
|
+
options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
|
|
461
|
+
): Promise<string | null> {
|
|
462
|
+
if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
|
|
463
|
+
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
464
|
+
const shape = lifecycleLogRowRefusal(log);
|
|
465
|
+
if (shape) return shape;
|
|
466
|
+
const derived = lifecycleTablesFromDeclared(declared);
|
|
467
|
+
if ('refusal' in derived) return derived.refusal;
|
|
468
|
+
const roles = options.applicationRoles;
|
|
469
|
+
if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
|
|
470
|
+
const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
|
|
471
|
+
&& !lifecycleManifestOf(object)).map(object => object.identity).sort();
|
|
472
|
+
if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
|
|
473
|
+
return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
|
|
474
|
+
nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared) },
|
|
475
|
+
{ lifecycleRole: options.lifecycleRole });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Live = fires in an ordinary session ('O' origin/local, 'A' always; not 'R' replica-only or 'D')
|
|
479
|
+
// and runs the table's own generated transition function, not merely a trigger with its name.
|
|
480
|
+
const LIVE_TRANSITIONS_SQL = `SELECT n.nspname || '.' || c.relname AS "table" FROM pg_catalog.pg_trigger g
|
|
481
|
+
JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
482
|
+
JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
|
|
483
|
+
WHERE NOT g.tgisinternal AND g.tgenabled IN ('O', 'A') AND g.tgname = c.relname || '_lifecycle_transition'
|
|
484
|
+
AND p.pronamespace = c.relnamespace AND p.proname = c.relname || '_lifecycle_transition'`;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* --state-only skips the derived layer, so a lifecycle table whose transition trigger is not live
|
|
488
|
+
* would keep its DELETE policies with nothing turning a DELETE into a soft delete.
|
|
489
|
+
*/
|
|
490
|
+
export async function lifecycleStateOnlyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
|
|
491
|
+
const lifecycle = models.filter(model => model.lifecycle === true);
|
|
492
|
+
if (lifecycle.length === 0) return null;
|
|
493
|
+
const live = new Set((await runner(LIVE_TRANSITIONS_SQL)).map(row => row.table));
|
|
494
|
+
const missing = lifecycle.map(model => `${model.schema}.${model.table}`).filter(table => !live.has(table)).sort();
|
|
495
|
+
if (missing.length === 0) return null;
|
|
496
|
+
return `--state-only skips the lifecycle triggers, and ${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no live transition trigger, `
|
|
497
|
+
+ 'so a DELETE there would remove rows for real. Run db:apply without --state-only.';
|
|
498
|
+
}
|