@everystack/cli 0.4.70 → 0.4.71
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 +39 -1
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/commands/db-reconcile.ts +2 -0
- package/src/cli/commands/db.ts +142 -2
- package/src/cli/derived-apply.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ 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.71] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- wire declared ownership into db:provision (B6, CLI half)
|
|
12
|
+
- reconcile refusals point at db:provision, not hand-SQL (B8)
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- BLOCKER A — reorder ownership convergence AFTER the secret write
|
|
17
|
+
- round-2 BLOCKER-1 — run ownership convergence on the credentials no-op path too
|
|
18
|
+
- align diag header assertion with the current source
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- brownfield ownership convergence — db:provision ENACT + ADOPT (B10)
|
|
23
|
+
|
|
7
24
|
## [0.4.70] - 2026-09-02
|
|
8
25
|
|
|
9
26
|
### Added
|
|
@@ -38,12 +55,33 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
38
55
|
- the two-class refusal doctrine for db:apply (p3-doctrine)
|
|
39
56
|
- the db:apply version-skew refusal and read-disagreement escalation
|
|
40
57
|
|
|
41
|
-
## [
|
|
58
|
+
## [0.4.69] - 2026-08-24
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- explain paren-only predicate churn instead of leaving it a mystery
|
|
63
|
+
- hedge the paren-only note — it cannot prove "not a change"
|
|
64
|
+
|
|
65
|
+
## [0.4.68] - 2026-08-24
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- round-trip a text_col = 'literal' predicate — strip the implicit ::text cast
|
|
70
|
+
- harden the ::text strip — whitespace-tolerant array guard, stacked-cast fixpoint
|
|
71
|
+
|
|
72
|
+
## [0.4.67] - 2026-08-22
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- auto-generated changelogs from conventional commits
|
|
42
77
|
|
|
43
78
|
### Fixed
|
|
44
79
|
|
|
45
80
|
- secrets test expected .js extension stripped by Metro compat
|
|
46
81
|
- async RSA key generation — unblocks the event loop in parallel test runs
|
|
82
|
+
- db:provision is additive-only — never reset an existing role's password
|
|
83
|
+
- address closing-gate review — pin ordering, guard half-done recovery
|
|
84
|
+
- address Fable red-team — rotated-stage guard, safe polarity
|
|
47
85
|
|
|
48
86
|
## [0.4.66] - 2026-08-15
|
|
49
87
|
|
package/README.md
CHANGED
|
@@ -230,7 +230,8 @@ everystack db:fork # fork a deployed stage's DB into a feat
|
|
|
230
230
|
|
|
231
231
|
# Security
|
|
232
232
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
|
233
|
-
everystack db:provision #
|
|
233
|
+
everystack db:provision # role chain + converge declared ownership (ENACT grants, ADOPT plain objects)
|
|
234
|
+
everystack db:provision --confirm # + ADOPT SECURITY DEFINER functions / views / matviews (runtime change)
|
|
234
235
|
everystack db:authz:pull|diff|test|owner|report # the authorization contract + red-team
|
|
235
236
|
|
|
236
237
|
# Backups (see docs/backups.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.71",
|
|
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.
|
|
181
|
+
"@everystack/server": "0.4.29"
|
|
182
182
|
},
|
|
183
183
|
"scripts": {
|
|
184
184
|
"test": "jest",
|
|
@@ -647,6 +647,8 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
647
647
|
info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply credential-free over the direct lane: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
|
|
648
648
|
} else if (/timed out|timeout|task timed out/i.test(String(result.error))) {
|
|
649
649
|
info('A large derived rebuild (dozens of objects) can exceed the ops-Lambda 900-second clock. Re-run credential-free with an unbounded clock: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct.');
|
|
650
|
+
} else if (/must be owner of/i.test(String(result.error))) {
|
|
651
|
+
info('This is a brownfield object owned by the master (the old tool created it), which the least-privilege operator cannot replace. Run `everystack db:provision --stage ' + (flags.stage ?? '<stage>') + '` first: it ADOPTS pre-existing objects to their declared owners (--confirm for SECURITY DEFINER), so the operator can then manage them. No hand-written ALTER … OWNER.');
|
|
650
652
|
}
|
|
651
653
|
process.exit(1);
|
|
652
654
|
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -304,14 +304,35 @@ export function provisionPreservedIsRecoverable(args: {
|
|
|
304
304
|
* same seam as pipeline-loader). */
|
|
305
305
|
export interface ServerProvision {
|
|
306
306
|
runProvision(
|
|
307
|
-
payload: {
|
|
307
|
+
payload: {
|
|
308
|
+
authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean;
|
|
309
|
+
declaredOwners?: Array<{ owner: string; schema: string }>;
|
|
310
|
+
declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
|
|
311
|
+
confirm?: boolean;
|
|
312
|
+
},
|
|
308
313
|
deps: {
|
|
309
314
|
execute: (sql: string) => Promise<unknown>;
|
|
310
315
|
connection?: { host: string; port?: number | string; database: string } | null;
|
|
311
316
|
connectProbe?: (url: string) => Promise<void>;
|
|
312
317
|
roleHasLogin?: (role: string) => Promise<boolean>;
|
|
318
|
+
query?: (sql: string) => Promise<any[]>;
|
|
319
|
+
master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
|
|
313
320
|
},
|
|
314
321
|
): Promise<any>;
|
|
322
|
+
/** ENACT + ADOPT, run SEPARATELY and AFTER the secret is written (the lockout-safe ordering). */
|
|
323
|
+
convergeOwnership(
|
|
324
|
+
payload: {
|
|
325
|
+
declaredOwners?: Array<{ owner: string; schema: string }>;
|
|
326
|
+
declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
|
|
327
|
+
confirm?: boolean;
|
|
328
|
+
adminRole?: string;
|
|
329
|
+
},
|
|
330
|
+
deps: {
|
|
331
|
+
execute: (sql: string) => Promise<unknown>;
|
|
332
|
+
query?: (sql: string) => Promise<any[]>;
|
|
333
|
+
master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
|
|
334
|
+
},
|
|
335
|
+
): Promise<{ ownerEnactment?: { enacted: number; refusals: string[] }; ownerAdoption?: { reassigned: number; manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>; findings: Array<{ severity: string; identity: string; detail: string }>; refusals: string[] } }>;
|
|
315
336
|
}
|
|
316
337
|
|
|
317
338
|
/**
|
|
@@ -450,6 +471,39 @@ export function declaredSearchPath(schemas: string[]): string[] {
|
|
|
450
471
|
return nonPublic.length ? [...nonPublic, 'public'] : [];
|
|
451
472
|
}
|
|
452
473
|
|
|
474
|
+
/** Kinds ADOPT can reassign — triggers have no owner, 'sql' escape-hatch objects are opaque. */
|
|
475
|
+
const ADOPTABLE_KINDS = new Set(['function', 'view', 'materialized view']);
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Read the declared ownership out of the compiled derived source: the unique (owner, schema) pairs
|
|
479
|
+
* to ENACT, and the function/view/matview objects to ADOPT. `secdef` is read from the object's own
|
|
480
|
+
* CREATE (the descriptor compiler emits SECURITY DEFINER into it), so the confirm gate sees which
|
|
481
|
+
* reassignments change runs-as. Objects without a declared owner are left alone (reconcile creates
|
|
482
|
+
* them owned by the operator, no convergence needed).
|
|
483
|
+
*/
|
|
484
|
+
export function extractDeclaredOwnership(objects: Array<{ kind: string; schema: string; name: string; identity: string; sql: string; owner?: string }>): {
|
|
485
|
+
declaredOwners: Array<{ owner: string; schema: string }>;
|
|
486
|
+
declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
|
|
487
|
+
} {
|
|
488
|
+
const seen = new Set<string>();
|
|
489
|
+
const declaredOwners: Array<{ owner: string; schema: string }> = [];
|
|
490
|
+
const declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
|
|
491
|
+
for (const o of objects) {
|
|
492
|
+
if (!o.owner) continue;
|
|
493
|
+
const key = `${o.owner}\t${o.schema}`;
|
|
494
|
+
if (!seen.has(key)) { seen.add(key); declaredOwners.push({ owner: o.owner, schema: o.schema }); }
|
|
495
|
+
if (ADOPTABLE_KINDS.has(o.kind)) {
|
|
496
|
+
declaredObjects.push({
|
|
497
|
+
identity: o.identity, schema: o.schema, name: o.name,
|
|
498
|
+
kind: o.kind as 'function' | 'view' | 'materialized view',
|
|
499
|
+
secdef: /SECURITY\s+DEFINER/i.test(o.sql),
|
|
500
|
+
currentOwner: '', declaredOwner: o.owner,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return { declaredOwners, declaredObjects };
|
|
505
|
+
}
|
|
506
|
+
|
|
453
507
|
/**
|
|
454
508
|
* Every schema the API connection must reach — the union of the TABLE schemas (the models
|
|
455
509
|
* barrel) and the DERIVED-object schemas (views/matviews declared via
|
|
@@ -838,6 +892,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
838
892
|
// authenticator flip doesn't go dark on bare-ref matview reads. Best-effort: a modelless or
|
|
839
893
|
// all-public app sets no path (unchanged behavior).
|
|
840
894
|
let declaredSchemas: string[] = [];
|
|
895
|
+
// Declared ownership (brownfield convergence) — the (owner, schema) pairs to ENACT and the
|
|
896
|
+
// function/view/matview objects to ADOPT, read from the same barrel as the schema set.
|
|
897
|
+
let declaredOwners: Array<{ owner: string; schema: string }> = [];
|
|
898
|
+
let declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
|
|
841
899
|
try {
|
|
842
900
|
const { resolveModelsPath } = await import('../models-path');
|
|
843
901
|
const { loadModels } = await import('./db-generate');
|
|
@@ -846,7 +904,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
846
904
|
try {
|
|
847
905
|
const { loadDeclaredDerived } = await import('../declared-derived');
|
|
848
906
|
const declared = await loadDeclaredDerived(flags.models);
|
|
849
|
-
if (declared)
|
|
907
|
+
if (declared) {
|
|
908
|
+
derivedObjects = declared.objects;
|
|
909
|
+
({ declaredOwners, declaredObjects } = extractDeclaredOwnership(declared.objects));
|
|
910
|
+
}
|
|
850
911
|
} catch {
|
|
851
912
|
// No derived layer resolvable — the table schemas still resolve correctly.
|
|
852
913
|
}
|
|
@@ -856,6 +917,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
856
917
|
}
|
|
857
918
|
const searchPath = declaredSearchPath(declaredSchemas);
|
|
858
919
|
if (searchPath.length) info(` Declared search_path (set as the role default via ALTER ROLE): ${searchPath.join(', ')}`);
|
|
920
|
+
const confirm = !!flags.confirm;
|
|
921
|
+
const ownershipPayload = { declaredOwners, declaredObjects, confirm };
|
|
859
922
|
|
|
860
923
|
// Generate BOTH login passwords and keep them in memory only — they are written straight
|
|
861
924
|
// into the secret store and are NEVER printed, logged, or returned to a human.
|
|
@@ -870,6 +933,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
870
933
|
const { createUrlRunner } = await import('../db-source');
|
|
871
934
|
const { runner, end } = await createUrlRunner(url);
|
|
872
935
|
try {
|
|
936
|
+
// Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
|
|
873
937
|
return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
|
|
874
938
|
execute: async (statement) => runner(statement),
|
|
875
939
|
connection: parseUrlConnection(url),
|
|
@@ -950,6 +1014,61 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
950
1014
|
success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
|
|
951
1015
|
}
|
|
952
1016
|
|
|
1017
|
+
// Ownership convergence runs AFTER the secret write (below) — never before. Declared here so the
|
|
1018
|
+
// final exit status can see it.
|
|
1019
|
+
let ownershipIncomplete = false;
|
|
1020
|
+
|
|
1021
|
+
// ENACT + ADOPT, factored out because it must run on BOTH exit paths: the normal one (after the
|
|
1022
|
+
// secret write) AND the additive-only NO-OP path (credentials unchanged, so no secret is written —
|
|
1023
|
+
// but the owners in the model may have changed and still need converging). Running it only on the
|
|
1024
|
+
// normal path made the whole feature a no-op on a re-provision, the common brownfield case: the
|
|
1025
|
+
// stage is already provisioned, the operator adds `owner:` declarations (or re-runs the
|
|
1026
|
+
// review-then-`--confirm` flow), and nothing converged. Credentials are already saved on both
|
|
1027
|
+
// paths by the time this runs, so it can strand nothing.
|
|
1028
|
+
const runConvergence = async (): Promise<void> => {
|
|
1029
|
+
if (!declaredOwners.length && !declaredObjects.length) return;
|
|
1030
|
+
const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole };
|
|
1031
|
+
let owned: any = {};
|
|
1032
|
+
try {
|
|
1033
|
+
if (directUrl) {
|
|
1034
|
+
// The direct venue is ONE privileged connection = operator read AND master; ADOPT's live
|
|
1035
|
+
// master_can_act probe gates it (an under-privileged direct connection refuses precisely).
|
|
1036
|
+
const { convergeOwnership } = await loadServerProvision();
|
|
1037
|
+
const { createUrlRunner } = await import('../db-source');
|
|
1038
|
+
const { runner, end } = await createUrlRunner(directUrl);
|
|
1039
|
+
try {
|
|
1040
|
+
owned = await convergeOwnership(ownerPayload, {
|
|
1041
|
+
execute: async (s) => runner(s),
|
|
1042
|
+
query: async (s) => (await runner(s)) as any[],
|
|
1043
|
+
master: { execute: async (s) => runner(s), query: async (s) => (await runner(s)) as any[] },
|
|
1044
|
+
});
|
|
1045
|
+
} finally {
|
|
1046
|
+
await end?.();
|
|
1047
|
+
}
|
|
1048
|
+
} else {
|
|
1049
|
+
owned = await invokeAction(config.region, opsFunction(config), 'db:converge-ownership', ownerPayload);
|
|
1050
|
+
}
|
|
1051
|
+
} catch (err: any) {
|
|
1052
|
+
warn(`Ownership convergence failed (credentials are already saved — nothing stranded): ${err.message}`);
|
|
1053
|
+
ownershipIncomplete = true;
|
|
1054
|
+
}
|
|
1055
|
+
const enact = owned.ownerEnactment;
|
|
1056
|
+
if (enact) {
|
|
1057
|
+
if (enact.enacted) success(`Owner enactment: ${enact.enacted} grant(s) applied.`);
|
|
1058
|
+
for (const r of enact.refusals) { warn(`Owner enactment: ${r}`); ownershipIncomplete = true; }
|
|
1059
|
+
}
|
|
1060
|
+
const adopt = owned.ownerAdoption;
|
|
1061
|
+
if (adopt) {
|
|
1062
|
+
if (adopt.manifest?.length) {
|
|
1063
|
+
info('Ownership adoption — objects reassigned:');
|
|
1064
|
+
for (const m of adopt.manifest) info(` ${m.identity} [${m.kind}${m.secdef ? ', SECURITY DEFINER' : ''}] ${m.from} → ${m.to}`);
|
|
1065
|
+
}
|
|
1066
|
+
for (const f of adopt.findings ?? []) warn(` audit (${f.severity}): ${f.identity} — ${f.detail}`);
|
|
1067
|
+
if (adopt.reassigned) success(`Ownership adoption: ${adopt.reassigned} object(s) reassigned (one transaction).`);
|
|
1068
|
+
for (const r of adopt.refusals ?? []) { warn(`Ownership adoption: ${r}`); ownershipIncomplete = true; }
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
953
1072
|
// Additive-only: if the login roles already existed as live logins, provision changed no
|
|
954
1073
|
// credential — so there is nothing to write and no host to resolve. Never touch the secret
|
|
955
1074
|
// store on a no-op re-provision (the footgun that reset a live password then locked the stage out).
|
|
@@ -966,6 +1085,13 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
966
1085
|
});
|
|
967
1086
|
if (recoverable.ok) {
|
|
968
1087
|
success(`Roles already provisioned — passwords and secrets left unchanged. Verify the live credential with \`everystack db:doctor --stage ${flags.stage}\`; run \`everystack db:provision --rotate\` to change credentials.`);
|
|
1088
|
+
// Credentials are a no-op, but declared ownership may still need converging — this is the
|
|
1089
|
+
// re-provision path (add owners, or the review-then---confirm retry). Run it, then exit.
|
|
1090
|
+
await runConvergence();
|
|
1091
|
+
if (ownershipIncomplete) {
|
|
1092
|
+
warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
969
1095
|
return;
|
|
970
1096
|
}
|
|
971
1097
|
fail(recoverable.reason);
|
|
@@ -1028,6 +1154,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1028
1154
|
success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
|
|
1029
1155
|
for (const note of plan.notes) info(` ${note}`);
|
|
1030
1156
|
for (const warning of plan.warnings) info(` ⚠ ${warning}`);
|
|
1157
|
+
|
|
1158
|
+
// OWNERSHIP CONVERGENCE — strictly AFTER the secret write. The credential is now safely in the
|
|
1159
|
+
// store, so a stall or a hard kill in ENACT/ADOPT can strand nothing: the worst case is an
|
|
1160
|
+
// un-converged owner, exactly the state db:reconcile refuses on (and we exit non-zero for it).
|
|
1161
|
+
await runConvergence();
|
|
1162
|
+
|
|
1031
1163
|
console.log('');
|
|
1032
1164
|
info('Finish the wiring in sst.config.ts (one-time):');
|
|
1033
1165
|
info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
|
|
@@ -1044,6 +1176,14 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1044
1176
|
info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
|
|
1045
1177
|
}
|
|
1046
1178
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
1179
|
+
|
|
1180
|
+
// Credentials are done; ownership was left un-converged. Exit non-zero so CI and the operator act
|
|
1181
|
+
// on the refusals above (run the named SQL as master, or re-run with --confirm). db:reconcile
|
|
1182
|
+
// would refuse on exactly this state, so this is a real, actionable failure — not a warning to skip.
|
|
1183
|
+
if (ownershipIncomplete) {
|
|
1184
|
+
warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1047
1187
|
}
|
|
1048
1188
|
|
|
1049
1189
|
/** Render a doctor report — identical output whichever venue produced it. */
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -408,7 +408,10 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
408
408
|
if (!missing.size && !unassumable.size && !noCreate.length) return null;
|
|
409
409
|
|
|
410
410
|
const lines: string[] = [
|
|
411
|
-
`declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied
|
|
411
|
+
`declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
|
|
412
|
+
`The one-command fix is \`everystack db:provision\`: it ENACTS these grants (and ADOPTS any`,
|
|
413
|
+
`pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
|
|
414
|
+
`The exact statements, if you must run them by hand:`,
|
|
412
415
|
];
|
|
413
416
|
for (const owner of [...missing].sort()) {
|
|
414
417
|
lines.push(
|