@everystack/cli 0.4.49 → 0.4.51
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 +1 -1
- package/src/cli/authz-derive.ts +5 -3
- package/src/cli/commands/db-check.ts +35 -4
- package/src/cli/commands/db-generate.ts +1 -1
- package/src/cli/commands/db-plan.ts +16 -5
- package/src/cli/commands/db-pull.ts +23 -4
- package/src/cli/db-build.ts +60 -0
- package/src/cli/derived-lint.ts +42 -5
- package/src/cli/derived-render.ts +31 -6
- package/src/cli/index.ts +1 -1
- package/src/cli/migration-compile.ts +20 -7
- package/src/cli/model-barrel-lint.ts +115 -0
- package/src/cli/model-render.ts +70 -10
- package/src/cli/schema-introspect.ts +37 -2
- package/src/plugin.ts +23 -5
package/package.json
CHANGED
package/src/cli/authz-derive.ts
CHANGED
|
@@ -64,8 +64,10 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
|
|
|
64
64
|
DELETE: 'delete',
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
/** The roles the compiler itself emits policies for; anything else is app-specific.
|
|
68
|
-
|
|
67
|
+
/** The roles the compiler itself emits policies for; anything else is app-specific.
|
|
68
|
+
* Exported so the DERIVED renderer applies the identical rule — the views path once had
|
|
69
|
+
* its own idea of which grantees to render, and the two disagreed inside one pull. */
|
|
70
|
+
export const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
73
|
* The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
|
|
@@ -75,7 +77,7 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
|
|
|
75
77
|
* an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
|
|
76
78
|
* would GOVERN it — turning a rendering decision into an access decision for every table.
|
|
77
79
|
*/
|
|
78
|
-
const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
|
|
80
|
+
export const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
|
|
79
81
|
|
|
80
82
|
/** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
|
|
81
83
|
function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
|
|
@@ -37,7 +37,8 @@ import { compileDrizzleSource } from '../schema-source.js';
|
|
|
37
37
|
import { currentGitRef } from '../state-apply.js';
|
|
38
38
|
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
|
|
39
39
|
import { resolveModelsPath } from '../models-path.js';
|
|
40
|
-
import { loadModels } from './db-generate.js';
|
|
40
|
+
import { loadModels, loadModules } from './db-generate.js';
|
|
41
|
+
import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '../model-barrel-lint.js';
|
|
41
42
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
42
43
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
43
44
|
import type { SourceObject } from '../derived-source.js';
|
|
@@ -67,6 +68,10 @@ export interface CheckFinding {
|
|
|
67
68
|
|
|
68
69
|
export interface StaticCheckInput {
|
|
69
70
|
modelsPath: string;
|
|
71
|
+
/** Barrel siblings and what each declares — the "on disk but ungoverned" scan. */
|
|
72
|
+
modelFileScans?: ModelFileScan[];
|
|
73
|
+
/** The module's extensions — the bootstrap the from-scratch compose needs. */
|
|
74
|
+
extensions?: string[];
|
|
70
75
|
/** null = the barrel did not load — the gate's first failure. */
|
|
71
76
|
models: ModelDescriptor[] | null;
|
|
72
77
|
modelsError?: string;
|
|
@@ -104,6 +109,17 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
104
109
|
}
|
|
105
110
|
findings.push({ level: 'ok', area: 'models', message: `${input.models.length} model(s) loaded from ${input.modelsPath}` });
|
|
106
111
|
|
|
112
|
+
// A file on disk the barrel never exported is outside governance while looking declared.
|
|
113
|
+
// FAIL, not warn: it is absent from the fingerprint, so nothing downstream will ever
|
|
114
|
+
// notice, and the only other signal is a model COUNT nobody can compare against.
|
|
115
|
+
const barrelGaps = findUnexportedModels(
|
|
116
|
+
input.modelFileScans ?? [],
|
|
117
|
+
new Set(input.models.map((m) => `${m.schema}.${m.table}`)),
|
|
118
|
+
);
|
|
119
|
+
for (const gap of barrelGaps) {
|
|
120
|
+
findings.push({ level: 'fail', area: 'models', message: gap.message });
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
// The semantic merge conflict git can't see: two branches declaring the
|
|
108
124
|
// same table both merge cleanly — the merged state must still compose.
|
|
109
125
|
const seen = new Map<string, number>();
|
|
@@ -144,7 +160,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
144
160
|
|
|
145
161
|
try {
|
|
146
162
|
compileDeclaredState(input.models);
|
|
147
|
-
const statements = compileMigration(input.models, { sequences: input.sequences });
|
|
163
|
+
const statements = compileMigration(input.models, { sequences: input.sequences, extensions: input.extensions });
|
|
148
164
|
findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
|
|
149
165
|
} catch (err: any) {
|
|
150
166
|
findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
|
|
@@ -281,7 +297,7 @@ export interface EphemeralComposeResult {
|
|
|
281
297
|
export async function executeEphemeralCompose(
|
|
282
298
|
adminUrl: string,
|
|
283
299
|
models: ModelDescriptor[],
|
|
284
|
-
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[] } = {},
|
|
300
|
+
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
|
|
285
301
|
): Promise<EphemeralComposeResult> {
|
|
286
302
|
const database = `escheck_${process.pid}_${Date.now()}`;
|
|
287
303
|
await createDatabase(adminUrl, database);
|
|
@@ -289,6 +305,7 @@ export async function executeEphemeralCompose(
|
|
|
289
305
|
const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
|
|
290
306
|
declared: opts.declared,
|
|
291
307
|
sequences: opts.sequences,
|
|
308
|
+
extensions: opts.extensions,
|
|
292
309
|
actor: opts.actor ?? 'db:check',
|
|
293
310
|
gitRef: opts.gitRef ?? null,
|
|
294
311
|
});
|
|
@@ -315,6 +332,19 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
315
332
|
modelsError = err.message;
|
|
316
333
|
}
|
|
317
334
|
|
|
335
|
+
// Only meaningful once the barrel itself loaded — otherwise every sibling reads as
|
|
336
|
+
// ungoverned and the real error is buried under the noise.
|
|
337
|
+
const modelFileScans = models ? await scanModelDirectory(modelsPath) : [];
|
|
338
|
+
|
|
339
|
+
// The module carries the extensions; `models` alone cannot. Without them the from-scratch
|
|
340
|
+
// compose builds a schema whose column types do not exist.
|
|
341
|
+
let extensions: string[] = [];
|
|
342
|
+
if (models) {
|
|
343
|
+
try {
|
|
344
|
+
extensions = [...new Set((await loadModules(modelsPath)).flatMap((m) => m.extensions ?? []))];
|
|
345
|
+
} catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
|
|
346
|
+
}
|
|
347
|
+
|
|
318
348
|
const artifactPath = flags['schema-out'] || DEFAULT_SCHEMA_OUT;
|
|
319
349
|
let artifactSource: string | null = null;
|
|
320
350
|
try {
|
|
@@ -349,7 +379,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
349
379
|
} catch { /* no baseline — see above */ }
|
|
350
380
|
|
|
351
381
|
const findings = runStaticChecks({
|
|
352
|
-
modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
|
|
382
|
+
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, sqlDirRetired,
|
|
353
383
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
354
384
|
moduleModels: declaredDb?.models ?? null,
|
|
355
385
|
baselineSource,
|
|
@@ -374,6 +404,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
374
404
|
gitRef: currentGitRef(),
|
|
375
405
|
declared: declaredDb?.objects,
|
|
376
406
|
sequences: declaredDb?.sequences,
|
|
407
|
+
extensions,
|
|
377
408
|
});
|
|
378
409
|
for (const line of ephemeral.report) info(` ${line}`);
|
|
379
410
|
} catch (err: any) {
|
|
@@ -72,7 +72,7 @@ export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]>
|
|
|
72
72
|
* COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
|
|
73
73
|
* older models-only barrel still gets a schema+authz init, just no package functions).
|
|
74
74
|
*/
|
|
75
|
-
async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
75
|
+
export async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
76
76
|
const abs = path.resolve(modelsPath);
|
|
77
77
|
let mod: any;
|
|
78
78
|
try {
|
|
@@ -221,6 +221,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// The class rides on the ARTIFACT, not only the terminal — a reviewer reading
|
|
225
|
+
// db.plan.json had the aggregate and no way to reach the statements behind it, so
|
|
226
|
+
// `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
|
|
227
|
+
//
|
|
228
|
+
// Attached HERE, before the plan is serialized AND before planHash is taken, because
|
|
229
|
+
// both must see the same object. It used to be assigned after the write, onto an
|
|
230
|
+
// instance nobody read again: the artifact carried no adoption at all, and the summary
|
|
231
|
+
// counted statements the file could not show. Hashing after attaching also keeps the
|
|
232
|
+
// ref db:plan prints equal to the one db:apply computes for that file — `planHash` is
|
|
233
|
+
// sha256 over the WHOLE plan, so a field added later silently changes the identity.
|
|
234
|
+
//
|
|
235
|
+
// Re-DERIVED from live every mint, never stored across mints: a recorded claim about
|
|
236
|
+
// what the models fail to capture goes stale the moment someone closes the gap.
|
|
237
|
+
const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
|
|
238
|
+
plan.adoption = adoption.statements;
|
|
239
|
+
|
|
224
240
|
const body = JSON.stringify(plan, null, 2) + '\n';
|
|
225
241
|
if (out === '-') {
|
|
226
242
|
console.log(body);
|
|
@@ -238,16 +254,11 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
238
254
|
// fail to capture goes stale the moment someone closes the gap, and a stale note is worse
|
|
239
255
|
// than none. `capability` is the count that says "this plan removes something the model
|
|
240
256
|
// cannot express" — the operator reading this is the last one who can catch it.
|
|
241
|
-
const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
|
|
242
257
|
const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
|
|
243
258
|
if (adoptionTotal > 0) {
|
|
244
259
|
info(`${adoptionTotal} authorization statement(s), by why they exist:`);
|
|
245
260
|
for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
|
|
246
261
|
}
|
|
247
|
-
// The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
|
|
248
|
-
// db.plan.json had the aggregate and no way to reach the statements behind it, so
|
|
249
|
-
// `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
|
|
250
|
-
plan.adoption = adoption.statements;
|
|
251
262
|
|
|
252
263
|
// WHO owns the tables this plan authorizes, and does that owner obey the policies it
|
|
253
264
|
// is about to write? Re-derived live every mint, never stored — the owner is a fact
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
4
|
* db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
|
|
5
|
-
* [--derived-out <file.ts>] [--abilities public-read]
|
|
5
|
+
* [--derived-out <file.ts>] [--abilities live|public-read]
|
|
6
6
|
*
|
|
7
7
|
* `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
|
|
8
8
|
* baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
|
|
@@ -108,7 +108,12 @@ export function derivedImportSpecifier(out: string, derivedOut: string): string
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
111
|
-
|
|
111
|
+
// A LIST: `--schema public,auth,metrics`. One value still works. Without this there was
|
|
112
|
+
// no way to adopt a multi-schema database — `--out <dir>` regenerates the barrel, so a
|
|
113
|
+
// second pull replaced the first schema's models rather than joining them.
|
|
114
|
+
const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
|
|
115
|
+
if (schemas.length === 0) fail('--schema needs at least one schema name.');
|
|
116
|
+
const schema = schemas.length === 1 ? schemas[0] : schemas;
|
|
112
117
|
|
|
113
118
|
// --derived-out: the brownfield splice, first-class. A consumer with an existing
|
|
114
119
|
// hand-maintained barrel wants the derived layer as its own file — not codemodded
|
|
@@ -167,7 +172,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
167
172
|
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
168
173
|
session = lambdaSessionRunner(config.region, opsFunction(config));
|
|
169
174
|
}
|
|
170
|
-
note(`Introspecting live database (schema: ${
|
|
175
|
+
note(`Introspecting live database (schema: ${schemas.join(", ")})...`);
|
|
171
176
|
current = await introspectSchema(session);
|
|
172
177
|
// The derived layer rides the same pull (B5) — views/matviews/functions/sequences
|
|
173
178
|
// render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
|
|
@@ -358,7 +363,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
358
363
|
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
|
|
359
364
|
if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
|
|
360
365
|
if (abilities === 'commented') {
|
|
361
|
-
|
|
366
|
+
// `live` FIRST, and named. It is the mode brownfield adoption needs — derive the authz
|
|
367
|
+
// from grants and policies the database already has — and it was advertised nowhere:
|
|
368
|
+
// not in the usage line, not in --help, not here. The only place an operator met the
|
|
369
|
+
// word was the error text for an unknown preset, so the discovery path for the right
|
|
370
|
+
// flag was to guess a wrong one. A consumer and their agent both walked past it in one
|
|
371
|
+
// session, and it cost a misdiagnosis plus a needless overwrite of 30 model files.
|
|
372
|
+
//
|
|
373
|
+
// `public-read` is deliberately no longer the headline: on an existing database it
|
|
374
|
+
// stamps can('read') on EVERY table, which on a real schema means public read of
|
|
375
|
+
// credit_cards, transactions, emails and users.
|
|
376
|
+
note(
|
|
377
|
+
`Each model scaffolds its authz decision as comments — db:check fails until every model declares. `
|
|
378
|
+
+ `For an EXISTING database, re-pull with --abilities live to derive them from the grants and policies `
|
|
379
|
+
+ `already there. --abilities public-read stamps public-read/admin-write on every table — greenfield only.`,
|
|
380
|
+
);
|
|
362
381
|
} else {
|
|
363
382
|
note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
|
|
364
383
|
}
|
package/src/cli/db-build.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { SequenceDescriptor } from '@everystack/model';
|
|
|
17
17
|
import { compileDeclaredState } from './declared-diff.js';
|
|
18
18
|
import { createUrlRunner } from './db-source.js';
|
|
19
19
|
import { executeSync, buildSyncReport } from './commands/db-sync.js';
|
|
20
|
+
import { executeReconcile } from './commands/db-reconcile.js';
|
|
20
21
|
|
|
21
22
|
const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
22
23
|
|
|
@@ -93,6 +94,10 @@ export interface BuildOptions {
|
|
|
93
94
|
declared?: SourceObject[];
|
|
94
95
|
/** Standalone sequences (state — created before tables, in the fingerprint bar). */
|
|
95
96
|
sequences?: SequenceDescriptor[];
|
|
97
|
+
/** Extensions the declared state needs. Applied BEFORE anything else — a column typed
|
|
98
|
+
* `hstore` cannot be created until the type exists, and the sync path is a DIFF, which
|
|
99
|
+
* has no bootstrap phase of its own. `IF NOT EXISTS`, so re-running is free. */
|
|
100
|
+
extensions?: string[];
|
|
96
101
|
actor?: string | null;
|
|
97
102
|
gitRef?: string | null;
|
|
98
103
|
}
|
|
@@ -109,7 +114,62 @@ export async function buildIntoDatabase(
|
|
|
109
114
|
): Promise<BuildResult> {
|
|
110
115
|
const { runner, session, end } = await createUrlRunner(url);
|
|
111
116
|
try {
|
|
117
|
+
// Bootstrap FIRST: roles and tables both come after the types they use exist.
|
|
118
|
+
for (const ext of [...new Set(options.extensions ?? [])].sort()) {
|
|
119
|
+
await runner(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
|
|
120
|
+
}
|
|
121
|
+
// Schemas, for the same reason. This path is a DIFF (executeSync), and a diff has no
|
|
122
|
+
// bootstrap phase: `compileMigration` emits CREATE SCHEMA for every non-public schema a
|
|
123
|
+
// model lives in, but nothing on the sync path did — so a multi-schema declared state
|
|
124
|
+
// failed with `schema "auth" does not exist` before a single table was created. Taken
|
|
125
|
+
// from the MODELS and from the declared derived objects, because a schema can hold only
|
|
126
|
+
// functions (an `auth` of nothing but SECURITY DEFINER functions is a real shape).
|
|
127
|
+
const modelSchemas = models.map((m) => m.schema ?? 'public');
|
|
128
|
+
const derivedSchemas = (options.declared ?? []).map((o) => o.schema);
|
|
129
|
+
for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
|
|
130
|
+
await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
|
131
|
+
}
|
|
112
132
|
const createdRoles = await ensureContractRoles(runner, models);
|
|
133
|
+
|
|
134
|
+
// FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
|
|
135
|
+
// created, so `USING (user_id = auth.user_id())` cannot be created before
|
|
136
|
+
// `auth.user_id()` exists — and the sync path applies state (tables, RLS, policies)
|
|
137
|
+
// before the derived layer. On a real brownfield schema that is not an edge case: 18 of
|
|
138
|
+
// one consumer's policies call an `auth.*` function, so the from-scratch build failed
|
|
139
|
+
// with `function auth.user_id() does not exist` before any of them could be created.
|
|
140
|
+
//
|
|
141
|
+
// Only FUNCTIONS move: views and triggers depend on TABLES, so the layers genuinely
|
|
142
|
+
// interleave and "derived before state" would just fail the other way round.
|
|
143
|
+
//
|
|
144
|
+
// `check_function_bodies = off` for this window — the pg_dump restore idiom. A
|
|
145
|
+
// SQL-language function whose body reads a table that does not exist yet is a forward
|
|
146
|
+
// reference, not an error; plpgsql bodies are never validated at creation, so only the
|
|
147
|
+
// SQL-language ones need it. MEASURED, not assumed: dropping this SET puts the reference
|
|
148
|
+
// brownfield schema straight back to failing, so the escape is load-bearing.
|
|
149
|
+
//
|
|
150
|
+
// It is a BARE set, not SET LOCAL, and that is deliberate: reconcile opens its own
|
|
151
|
+
// transaction, so this pass has none to scope to. It is safe under the rule the repo's
|
|
152
|
+
// own guard documents — `createUrlRunner` gives this command a dedicated max:1
|
|
153
|
+
// connection it owns for the run and closes in a finally, never a pooled or shared one.
|
|
154
|
+
// RESET in a finally so the window closes even on failure.
|
|
155
|
+
//
|
|
156
|
+
// Provenance is recorded by this pass, so executeSync's own reconcile below sees the
|
|
157
|
+
// functions already managed and unchanged, and plans nothing for them.
|
|
158
|
+
const declaredFunctions = (options.declared ?? []).filter((o) => o.kind === 'function');
|
|
159
|
+
if (declaredFunctions.length > 0) {
|
|
160
|
+
await runner('SET check_function_bodies = off');
|
|
161
|
+
try {
|
|
162
|
+
const pre = await executeReconcile(runner, session, {
|
|
163
|
+
declared: declaredFunctions,
|
|
164
|
+
apply: true,
|
|
165
|
+
actor: options.actor ?? 'db-build',
|
|
166
|
+
gitRef: options.gitRef ?? null,
|
|
167
|
+
});
|
|
168
|
+
if (pre.refusal) throw new Error(`could not create declared functions first: ${pre.refusal}`);
|
|
169
|
+
} finally {
|
|
170
|
+
await runner('RESET check_function_bodies');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
113
173
|
const run = await executeSync(runner, session, models, {
|
|
114
174
|
declared: options.declared,
|
|
115
175
|
sequences: options.sequences,
|
package/src/cli/derived-lint.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
FunctionDescriptor, DependsOnRef,
|
|
33
33
|
} from '@everystack/model';
|
|
34
34
|
import { parseQualified } from './derived-source.js';
|
|
35
|
+
import { GOVERNED_VOCABULARY } from './authz-derive.js';
|
|
35
36
|
|
|
36
37
|
export interface DerivedAuthzGap {
|
|
37
38
|
/** `schema.name` of the descriptor that fails the gate. */
|
|
@@ -60,6 +61,10 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
|
|
|
60
61
|
* as zero-reach, which bricked every invoker view over a private table.
|
|
61
62
|
*/
|
|
62
63
|
export function tableReaches(m: ModelDescriptor, role: string): boolean {
|
|
64
|
+
// A recorded grant is reach too. PUBLIC SELECT is held by every role; a named grantee
|
|
65
|
+
// reaches itself. See grantsPublic — this is the half the gate kept forgetting.
|
|
66
|
+
if (grantsPublic(m.privileges, 'SELECT')) return true;
|
|
67
|
+
if (privilegeRoles(m.privileges, 'SELECT').includes(role)) return true;
|
|
63
68
|
for (const a of m.abilities) {
|
|
64
69
|
if (isColumnAbility(a)) {
|
|
65
70
|
// Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
|
|
@@ -79,6 +84,30 @@ export function tableReaches(m: ModelDescriptor, role: string): boolean {
|
|
|
79
84
|
return false;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* REACH comes from abilities AND from recorded privileges. Forgetting the second half is a
|
|
89
|
+
* bug this repo has now made three times — the 63-byte ACL truncation, the SECDEF caller
|
|
90
|
+
* gate, and this one — always the same shape: a fact recorded as `privileges` falls out of
|
|
91
|
+
* a downstream read and lands in neither set.
|
|
92
|
+
*
|
|
93
|
+
* `PUBLIC` is the case that matters. `GRANT … TO PUBLIC` is held by EVERY role, so a
|
|
94
|
+
* dependency carrying one is reachable by all of them, and a live PUBLIC grant is exactly
|
|
95
|
+
* what `db:pull` RECORDS rather than inventing an audience for. Reading abilities alone
|
|
96
|
+
* made a faithful pull fail to COMPILE, with no hand-edit available to fix it.
|
|
97
|
+
*/
|
|
98
|
+
function grantsPublic(privileges: Record<string, string[]> | undefined, privilege: string): boolean {
|
|
99
|
+
return Object.entries(privileges ?? {}).some(
|
|
100
|
+
([grantee, privs]) => grantee.toUpperCase() === 'PUBLIC' && privs.some((p) => p.toUpperCase() === privilege),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Grantees a recorded privilege names directly — reach for that role, nobody else. */
|
|
105
|
+
function privilegeRoles(privileges: Record<string, string[]> | undefined, privilege: string): string[] {
|
|
106
|
+
return Object.entries(privileges ?? {})
|
|
107
|
+
.filter(([, privs]) => privs.some((p) => p.toUpperCase() === privilege))
|
|
108
|
+
.map(([grantee]) => grantee);
|
|
109
|
+
}
|
|
110
|
+
|
|
82
111
|
/** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
|
|
83
112
|
function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
|
|
84
113
|
const roles = new Set<string>();
|
|
@@ -89,12 +118,15 @@ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<stri
|
|
|
89
118
|
roles.add('authenticated');
|
|
90
119
|
}
|
|
91
120
|
}
|
|
121
|
+
for (const r of privilegeRoles(d.privileges, 'SELECT')) roles.add(r);
|
|
92
122
|
return roles;
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
function functionRoles(fn: FunctionDescriptor): Set<string> {
|
|
96
126
|
// define-time already rejected bare can('execute') — every ability carries a role.
|
|
97
|
-
|
|
127
|
+
const roles = new Set(fn.abilities.map((a) => a.condition.role!));
|
|
128
|
+
for (const r of privilegeRoles(fn.privileges, 'EXECUTE')) roles.add(r);
|
|
129
|
+
return roles;
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
// ---------------------------------------------------------------------------
|
|
@@ -262,7 +294,7 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
|
|
|
262
294
|
}
|
|
263
295
|
switch (ref.kind) {
|
|
264
296
|
case 'view': {
|
|
265
|
-
if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
|
|
297
|
+
if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
|
|
266
298
|
// An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
|
|
267
299
|
if (ref.securityInvoker) {
|
|
268
300
|
const deeper = findUnreachable(role, ref.dependsOn, seen);
|
|
@@ -272,11 +304,11 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
|
|
|
272
304
|
}
|
|
273
305
|
case 'materialized view':
|
|
274
306
|
// A matview is its own snapshot: SELECT on the matview is the whole requirement.
|
|
275
|
-
if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
|
|
307
|
+
if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
|
|
276
308
|
break;
|
|
277
309
|
case 'function':
|
|
278
310
|
// The body calls it with the caller's rights — EXECUTE is part of reach.
|
|
279
|
-
if (!functionRoles(ref).has(role)) return identityOf(ref);
|
|
311
|
+
if (!functionRoles(ref).has(role) && !grantsPublic(ref.privileges, 'EXECUTE')) return identityOf(ref);
|
|
280
312
|
break;
|
|
281
313
|
case 'sql':
|
|
282
314
|
break; // opaque — never a false positive
|
|
@@ -296,7 +328,12 @@ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[
|
|
|
296
328
|
for (const d of derived) {
|
|
297
329
|
if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
|
|
298
330
|
const identity = identityOf(d);
|
|
299
|
-
|
|
331
|
+
// Only roles the declared world can SATISFY. A grantee outside the compiler's
|
|
332
|
+
// vocabulary is recorded fact on the view AND on the table (the tables path never
|
|
333
|
+
// renders one as an ability), so table-side reach for it is not expressible — checking
|
|
334
|
+
// it is a demand no model can ever meet. Their reach is adopted via authz-baseline,
|
|
335
|
+
// outside this gate. PUBLIC is governed and stays checked.
|
|
336
|
+
for (const role of [...relationRoles(d)].filter((r) => GOVERNED_VOCABULARY.has(r)).sort()) {
|
|
300
337
|
const unreachable = findUnreachable(role, d.dependsOn, new Set());
|
|
301
338
|
if (unreachable) {
|
|
302
339
|
gaps.push({
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
17
17
|
import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introspect.js';
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
|
-
import {
|
|
19
|
+
import { modelImportPath, renderFieldLines } from './model-render.js';
|
|
20
20
|
import { splitFunctionIdentity } from './pg-argtypes.js';
|
|
21
|
+
import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive.js';
|
|
21
22
|
|
|
22
23
|
export interface DerivedRenderResult {
|
|
23
24
|
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
@@ -100,12 +101,28 @@ function tsString(s: string): string {
|
|
|
100
101
|
*/
|
|
101
102
|
function splitRelationGrants(
|
|
102
103
|
grants: Record<string, string[]>,
|
|
103
|
-
): { abilities: string[]; privileges: Record<string, string[]
|
|
104
|
+
): { abilities: string[]; privileges: Record<string, string[]>; foreign: string[] } {
|
|
104
105
|
const readRoles = new Set<string>();
|
|
105
106
|
const privileges: Record<string, string[]> = {};
|
|
107
|
+
const foreign: string[] = [];
|
|
106
108
|
for (const [grantee, privs] of Object.entries(grants)) {
|
|
107
109
|
const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
|
|
108
|
-
|
|
110
|
+
// ABILITIES are limited to the compiler's own vocabulary (authz-derive's KNOWN_ROLES).
|
|
111
|
+
// A foreign grantee is FACT, not opinion, so it is RECORDED — never declared as an
|
|
112
|
+
// intended audience. The views path used to render any select-only grantee as an
|
|
113
|
+
// ability, so one pull could declare a role as an ABILITY on a view while the table
|
|
114
|
+
// recorded it as a foreign grantee, and the reachability gate then demanded table-side
|
|
115
|
+
// reach the tables path will never render. The contract contradicted itself.
|
|
116
|
+
//
|
|
117
|
+
// RECORDED, not dropped — and that is the part the obvious fix gets wrong. On a TABLE,
|
|
118
|
+
// "not rendered" is safe because the table reconciler leaves an ungoverned grantee
|
|
119
|
+
// alone. On a DERIVED object there is no such carve-out: diffObjectGrants unions the
|
|
120
|
+
// declared and live grantees, so a grantee we omit is a grantee we REVOKE. Measured:
|
|
121
|
+
// declared {anon,authenticated} against live +outbound_migrator plans
|
|
122
|
+
// `REVOKE SELECT, UPDATE ON public.v FROM outbound_migrator`. Dropping them would
|
|
123
|
+
// reintroduce the silent revoke this whole branch exists to prevent.
|
|
124
|
+
if (!GOVERNED_VOCABULARY.has(grantee)) foreign.push(grantee);
|
|
125
|
+
if (selectOnly && KNOWN_ROLES.has(grantee)) readRoles.add(grantee);
|
|
109
126
|
else privileges[grantee] = [...privs].sort();
|
|
110
127
|
}
|
|
111
128
|
const abilities: string[] = [];
|
|
@@ -115,7 +132,7 @@ function splitRelationGrants(
|
|
|
115
132
|
readRoles.delete('authenticated');
|
|
116
133
|
}
|
|
117
134
|
for (const role of [...readRoles].sort()) abilities.push(`can('read', { role: ${tsString(role)} })`);
|
|
118
|
-
return { abilities, privileges };
|
|
135
|
+
return { abilities, privileges, foreign: foreign.sort() };
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
/**
|
|
@@ -363,7 +380,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
363
380
|
// the abilities we can express would leave the write grants undeclared, so the next
|
|
364
381
|
// generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
|
|
365
382
|
// Declared == live means nothing is planned at all.
|
|
366
|
-
const { abilities, privileges } = splitRelationGrants(o.grants ?? {});
|
|
383
|
+
const { abilities, privileges, foreign } = splitRelationGrants(o.grants ?? {});
|
|
384
|
+
if (foreign.length) {
|
|
385
|
+
warnings.push(
|
|
386
|
+
`${o.identity}: grants exist for ${foreign.join(', ')} — recorded as \`privileges\`, not declared as ` +
|
|
387
|
+
'abilities. Abilities name the roles the model governs (anon/authenticated/admin); a grantee outside ' +
|
|
388
|
+
'that vocabulary is fact, not an intended audience. Recorded rather than dropped because a derived ' +
|
|
389
|
+
'object REVOKES any live grantee the declaration omits.',
|
|
390
|
+
)
|
|
391
|
+
}
|
|
367
392
|
const writeGrants = Object.values(privileges).some((ps) =>
|
|
368
393
|
ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
|
|
369
394
|
);
|
|
@@ -651,7 +676,7 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
|
|
|
651
676
|
lines.push(`import { ${result.imports.join(', ')} } from '@everystack/model';`);
|
|
652
677
|
}
|
|
653
678
|
const refs = result.modelRefs
|
|
654
|
-
.map((table) => ({ varName: knownTables.get(table)!, path:
|
|
679
|
+
.map((table) => ({ varName: knownTables.get(table)!, path: modelImportPath(table) }))
|
|
655
680
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
656
681
|
for (const ref of refs) lines.push(`import { ${ref.varName} } from '${ref.path}';`);
|
|
657
682
|
lines.push('', result.block, '');
|
package/src/cli/index.ts
CHANGED
|
@@ -359,7 +359,7 @@ Usage:
|
|
|
359
359
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
360
360
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
361
361
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
362
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored)
|
|
362
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
363
363
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
364
364
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
365
365
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
@@ -38,10 +38,21 @@ function emptyTable(table: string): TableContract {
|
|
|
38
38
|
* schema). Tables, then foreign keys, then authz — the order a fresh database must
|
|
39
39
|
* apply them in.
|
|
40
40
|
*/
|
|
41
|
-
export function compileMigration(
|
|
41
|
+
export function compileMigration(
|
|
42
|
+
models: ModelDescriptor[],
|
|
43
|
+
opts: CompileTableOptions & { sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
|
|
44
|
+
): string[] {
|
|
42
45
|
const sql: string[] = [];
|
|
43
46
|
const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
|
|
44
47
|
|
|
48
|
+
// 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
|
|
49
|
+
// compileModuleMigration has always emitted these; compileMigration had no way to,
|
|
50
|
+
// so db:check's from-scratch ring compiled a state whose column types did not exist
|
|
51
|
+
// and failed with `type "hstore" does not exist` on an otherwise clean schema.
|
|
52
|
+
// Deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) stays valid.
|
|
53
|
+
const extensions = [...new Set(opts.extensions ?? [])].sort();
|
|
54
|
+
sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
|
|
55
|
+
|
|
45
56
|
// 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
|
|
46
57
|
// before any table is created in it. public is the implicit default, never emitted.
|
|
47
58
|
const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
|
|
@@ -137,12 +148,14 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
|
|
|
137
148
|
export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
|
|
138
149
|
const sql: string[] = [];
|
|
139
150
|
|
|
140
|
-
// 1. Extensions
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
// 1+2. Extensions (the bootstrap) then schema + authz for every modeled table, plus the
|
|
152
|
+
// modules' standalone sequences. compileMigration owns the extension emission now, so
|
|
153
|
+
// the two entry points cannot disagree about the order or the quoting.
|
|
154
|
+
sql.push(...compileMigration(modules.flatMap((m) => m.models), {
|
|
155
|
+
...opts,
|
|
156
|
+
sequences: modules.flatMap((m) => m.sequences),
|
|
157
|
+
extensions: modules.flatMap((m) => m.extensions),
|
|
158
|
+
}));
|
|
146
159
|
|
|
147
160
|
// 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
|
|
148
161
|
// output is a self-contained multi-statement block applied as one unit.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-barrel-lint — the file that is on disk and outside governance.
|
|
3
|
+
*
|
|
4
|
+
* Every verb reads the BARREL: `db:check`, `db:plan` and `db:fingerprint` all call
|
|
5
|
+
* `loadModels(modelsPath)` and see exactly what it exports. So a model file sitting in
|
|
6
|
+
* `db/models/` that `index.ts` never imports does not exist as far as any of them is
|
|
7
|
+
* concerned — it is absent from the fingerprint, absent from the plan, and absent from the
|
|
8
|
+
* check — while looking entirely declared to a human reading the directory.
|
|
9
|
+
*
|
|
10
|
+
* That is the failure the declared-state model exists to prevent: a table quietly outside
|
|
11
|
+
* governance, with a file that says otherwise. It is also silent by construction, because
|
|
12
|
+
* the only signal is a COUNT ("30 model(s)") that matches nothing the reader can compare it
|
|
13
|
+
* to. A consumer lost an hour to it and drew a false stage-drift conclusion from the
|
|
14
|
+
* resulting one-statement plan.
|
|
15
|
+
*
|
|
16
|
+
* The scan is deliberately shallow: the barrel's OWN directory, non-recursive. A models
|
|
17
|
+
* directory is a flat directory of models by convention, and walking deeper would start
|
|
18
|
+
* flagging fixtures and generated output.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
25
|
+
|
|
26
|
+
/** One file's declared tables, as read off its own exports. */
|
|
27
|
+
export interface ModelFileScan {
|
|
28
|
+
/** Basename, relative to the barrel's directory — what the operator has to go open. */
|
|
29
|
+
file: string;
|
|
30
|
+
/** Qualified tables (`schema.table`) the file declares, in any order. */
|
|
31
|
+
tables: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UnexportedModelGap {
|
|
35
|
+
file: string;
|
|
36
|
+
/** The declared tables no verb can see, sorted. */
|
|
37
|
+
tables: string[];
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Files declaring a table the barrel never exported. Pure — the caller does the reading, so
|
|
43
|
+
* the rule is testable without a filesystem.
|
|
44
|
+
*
|
|
45
|
+
* Findings are per FILE, because the file is the unit that gets fixed (add it to the
|
|
46
|
+
* barrel, or delete it). Everything is sorted: import order is not a contract, and a lint
|
|
47
|
+
* whose output reorders between runs is one nobody can diff.
|
|
48
|
+
*/
|
|
49
|
+
export function findUnexportedModels(
|
|
50
|
+
scans: readonly ModelFileScan[],
|
|
51
|
+
loadedTables: ReadonlySet<string>,
|
|
52
|
+
): UnexportedModelGap[] {
|
|
53
|
+
const gaps: UnexportedModelGap[] = [];
|
|
54
|
+
for (const scan of scans) {
|
|
55
|
+
const missing = scan.tables.filter((t) => !loadedTables.has(t)).sort();
|
|
56
|
+
if (missing.length === 0) continue;
|
|
57
|
+
gaps.push({
|
|
58
|
+
file: scan.file,
|
|
59
|
+
tables: missing,
|
|
60
|
+
message:
|
|
61
|
+
`${scan.file} declares ${missing.join(', ')} but the barrel does not export it — so no verb can see it. ` +
|
|
62
|
+
`db:check, db:plan and db:fingerprint all read the barrel, which means the table is outside governance ` +
|
|
63
|
+
`while the file makes it look declared. Export it from the barrel, or delete the file.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return gaps.sort((a, b) => a.file.localeCompare(b.file));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A qualified table identity, matching the form the loaded models are keyed by. */
|
|
70
|
+
export function modelIdentity(m: ModelDescriptor): string {
|
|
71
|
+
return `${m.schema}.${m.table}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the barrel's sibling files and report what each one declares.
|
|
76
|
+
*
|
|
77
|
+
* Import failures are SKIPPED, not raised: a sibling that does not compile is a different
|
|
78
|
+
* problem with its own error path, and a lint that turns an unrelated broken file into a
|
|
79
|
+
* governance finding would send the operator to the wrong place. A file exporting no model
|
|
80
|
+
* simply scans as zero tables.
|
|
81
|
+
*/
|
|
82
|
+
export async function scanModelDirectory(modelsPath: string): Promise<ModelFileScan[]> {
|
|
83
|
+
const abs = path.resolve(modelsPath);
|
|
84
|
+
const dir = path.dirname(abs);
|
|
85
|
+
const barrel = path.basename(abs);
|
|
86
|
+
|
|
87
|
+
let entries: string[];
|
|
88
|
+
try {
|
|
89
|
+
entries = await fs.readdir(dir);
|
|
90
|
+
} catch {
|
|
91
|
+
return []; // Not a directory-shaped barrel (a single-module app); nothing to scan.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const scans: ModelFileScan[] = [];
|
|
95
|
+
for (const entry of entries.sort()) {
|
|
96
|
+
if (entry === barrel) continue;
|
|
97
|
+
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
|
|
98
|
+
let mod: Record<string, unknown>;
|
|
99
|
+
try {
|
|
100
|
+
mod = (await import(pathToFileURL(path.join(dir, entry)).href)) as Record<string, unknown>;
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const tables = Object.values(mod)
|
|
105
|
+
.filter((v): v is ModelDescriptor => (
|
|
106
|
+
typeof v === 'object' && v !== null
|
|
107
|
+
&& typeof (v as ModelDescriptor).table === 'string'
|
|
108
|
+
&& typeof (v as ModelDescriptor).schema === 'string'
|
|
109
|
+
&& Array.isArray((v as ModelDescriptor).abilities)
|
|
110
|
+
))
|
|
111
|
+
.map(modelIdentity);
|
|
112
|
+
scans.push({ file: entry, tables: [...new Set(tables)] });
|
|
113
|
+
}
|
|
114
|
+
return scans;
|
|
115
|
+
}
|
package/src/cli/model-render.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
|
|
|
61
61
|
|
|
62
62
|
export interface RenderOptions {
|
|
63
63
|
/** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
|
|
64
|
-
schema
|
|
64
|
+
/** One schema, or several — a database is not always one schema. Default `'public'`. */
|
|
65
|
+
schema?: string | string[];
|
|
65
66
|
/**
|
|
66
67
|
* The read-model scaffold. Default `'commented'`: every model carries the authz decision
|
|
67
68
|
* as a commented stanza (same guidance as the db:check gate — the model fails the gate
|
|
@@ -241,8 +242,25 @@ const INFRASTRUCTURE_TABLES = new Set([
|
|
|
241
242
|
* database can carry `public.__drizzle_migrations` — the journal is tooling state,
|
|
242
243
|
* never an app model. Exported so the command shell counts the same set it writes.
|
|
243
244
|
*/
|
|
244
|
-
export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableSchema[] {
|
|
245
|
-
|
|
245
|
+
export function pullableTables(snapshot: SchemaSnapshot, schema: string | string[]): TableSchema[] {
|
|
246
|
+
// A LIST, because a database is not one schema. `--schema` took a single value and
|
|
247
|
+
// `--out <dir>` regenerates the barrel, so pulling a second schema overwrote the first —
|
|
248
|
+
// there was no way to land a multi-schema database in one declared state at all.
|
|
249
|
+
//
|
|
250
|
+
// Matched on the exact prefix, never `startsWith(schema)` alone: `pub` must not match
|
|
251
|
+
// `public.users`.
|
|
252
|
+
const schemas = new Set(Array.isArray(schema) ? schema : [schema]);
|
|
253
|
+
return snapshot.tables.filter((t) => {
|
|
254
|
+
const dot = t.table.indexOf('.');
|
|
255
|
+
const owner = dot === -1 ? 'public' : t.table.slice(0, dot);
|
|
256
|
+
return schemas.has(owner) && !INFRASTRUCTURE_TABLES.has(bareName(t.table));
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The schema a qualified (or bare) table lives in — bare means public. */
|
|
261
|
+
function schemaOf(table: string): string {
|
|
262
|
+
const dot = table.indexOf('.');
|
|
263
|
+
return dot === -1 ? 'public' : table.slice(0, dot);
|
|
246
264
|
}
|
|
247
265
|
|
|
248
266
|
/**
|
|
@@ -258,7 +276,14 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableS
|
|
|
258
276
|
export function modelVarName(table: string): string {
|
|
259
277
|
const bare = bareName(table);
|
|
260
278
|
const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
|
|
261
|
-
|
|
279
|
+
const pascal = (word: string): string =>
|
|
280
|
+
word.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
281
|
+
// Non-public schemas are QUALIFIED, so `public.users` and `auth.users` can live in one
|
|
282
|
+
// barrel. Public stays bare, so an existing single-schema barrel does not churn and
|
|
283
|
+
// nothing renames when a second schema arrives. Same rule the derived renderer already
|
|
284
|
+
// uses for its own symbols.
|
|
285
|
+
const owner = schemaOf(table);
|
|
286
|
+
return owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular);
|
|
262
287
|
}
|
|
263
288
|
|
|
264
289
|
/**
|
|
@@ -511,7 +536,15 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
511
536
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
512
537
|
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
513
538
|
|
|
514
|
-
|
|
539
|
+
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
540
|
+
// qualification cannot ride in the first argument (that would make the table literally
|
|
541
|
+
// named `metrics.impressions`). Without it a multi-schema pull rendered
|
|
542
|
+
// `defineModel('impressions')`, which declares `public.impressions`, and the matview that
|
|
543
|
+
// selects FROM metrics.impressions then failed to build. Same idiom the derived renderer
|
|
544
|
+
// already uses for defineMaterializedTable.
|
|
545
|
+
const owner = schemaOf(table.table);
|
|
546
|
+
const schemaProp = owner === 'public' ? '' : ` schema: '${owner}',\n`;
|
|
547
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
|
|
515
548
|
}
|
|
516
549
|
|
|
517
550
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
@@ -548,6 +581,7 @@ function moduleFooter(
|
|
|
548
581
|
derived?: DerivedRenderResult,
|
|
549
582
|
multiline = false,
|
|
550
583
|
external?: ExternalDerived,
|
|
584
|
+
extensions?: string[],
|
|
551
585
|
): string {
|
|
552
586
|
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
553
587
|
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
@@ -570,6 +604,22 @@ function moduleFooter(
|
|
|
570
604
|
if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
|
|
571
605
|
keys.push('derived');
|
|
572
606
|
}
|
|
607
|
+
// Extensions are the BOOTSTRAP half of a declared state: `field.pgType('hstore')` compiles
|
|
608
|
+
// to a column whose type does not exist unless something ran CREATE EXTENSION first.
|
|
609
|
+
// `defineModule` has always accepted the key and migration-compile has always emitted
|
|
610
|
+
// `CREATE EXTENSION IF NOT EXISTS` from it — the renderer was the only missing link, and a
|
|
611
|
+
// consumer found it when db:check's from-scratch ring failed with `type "hstore" does not
|
|
612
|
+
// exist` on a state that was otherwise clean.
|
|
613
|
+
//
|
|
614
|
+
// ALL installed extensions, not only those reachable from a declared column type: an
|
|
615
|
+
// adopter's `pg_stat_statements` and `unaccent` reach no column, and a from-scratch
|
|
616
|
+
// database without them is not their database.
|
|
617
|
+
if (extensions?.length) {
|
|
618
|
+
// Sorted HERE, not just at the introspection: the renderer owns byte-stability, and a
|
|
619
|
+
// caller passing an unsorted list must not produce a diff against an identical database.
|
|
620
|
+
parts.push(`export const extensions = [${[...extensions].sort().map((e) => `'${e}'`).join(', ')}];`);
|
|
621
|
+
keys.push('extensions');
|
|
622
|
+
}
|
|
573
623
|
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
574
624
|
parts.push(`export const modules = [appModule];`);
|
|
575
625
|
return parts.join('\n\n');
|
|
@@ -611,7 +661,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
611
661
|
|
|
612
662
|
// Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
|
|
613
663
|
// so a fresh brownfield project lands on the same shape the framework composes.
|
|
614
|
-
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
|
|
664
|
+
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions);
|
|
615
665
|
|
|
616
666
|
const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
|
|
617
667
|
|
|
@@ -626,8 +676,18 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
626
676
|
* NAMING CONTRACT — changing this rule is a BREAKING CHANGE: index.ts and cross-file
|
|
627
677
|
* FK imports reference these paths. Pinned by naming-contract.test.ts.
|
|
628
678
|
*/
|
|
679
|
+
/** The import specifier a barrel (or a sibling file) uses for a model — the FILENAME rule
|
|
680
|
+
* minus the extension. Derived from modelFileName so the two can never disagree; they did,
|
|
681
|
+
* and a multi-schema pull emitted `import … from './refresh-tokens'` next to a file called
|
|
682
|
+
* `auth-refresh-tokens.ts`. */
|
|
683
|
+
export function modelImportPath(table: string): string {
|
|
684
|
+
return `./${modelFileName(table).replace(/\.ts$/, '')}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
629
687
|
export function modelFileName(table: string): string {
|
|
630
|
-
|
|
688
|
+
const owner = schemaOf(table);
|
|
689
|
+
const bare = bareName(table).replace(/_/g, '-');
|
|
690
|
+
return owner === 'public' ? `${bare}.ts` : `${owner.replace(/_/g, '-')}-${bare}.ts`;
|
|
631
691
|
}
|
|
632
692
|
|
|
633
693
|
/** The bare tables (other than itself) a table's rendered FKs reference within the pulled set. */
|
|
@@ -664,7 +724,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
664
724
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
665
725
|
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
|
|
666
726
|
const crossImports = referencedTables(t, known).map(
|
|
667
|
-
(target) => `import { ${modelVarName(target)} } from '
|
|
727
|
+
(target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
|
|
668
728
|
);
|
|
669
729
|
const header = [importHeader(block), ...crossImports].join('\n');
|
|
670
730
|
return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
|
|
@@ -683,13 +743,13 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
683
743
|
// The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
|
|
684
744
|
// or db:plan silently compares against models only.
|
|
685
745
|
...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
|
|
686
|
-
...names.map((n, i) => `import { ${n} } from '
|
|
746
|
+
...names.map((n, i) => `import { ${n} } from '${modelImportPath(tables[i].table)}';`),
|
|
687
747
|
].join('\n'),
|
|
688
748
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
689
749
|
// Re-exported so the barrel remains the one place that describes the database.
|
|
690
750
|
...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
|
|
691
751
|
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
692
|
-
moduleFooter(names, opts.derived, true, ext),
|
|
752
|
+
moduleFooter(names, opts.derived, true, ext, snapshot.extensions),
|
|
693
753
|
].join('\n\n');
|
|
694
754
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
695
755
|
|
|
@@ -105,6 +105,8 @@ export interface SchemaSnapshot {
|
|
|
105
105
|
enums?: EnumType[];
|
|
106
106
|
/** Standalone sequences (serial-owned ones excluded). Absent/empty when none. */
|
|
107
107
|
sequences?: SequenceSchema[];
|
|
108
|
+
/** Installed extensions, `plpgsql` excluded. Absent when the read predates this. */
|
|
109
|
+
extensions?: string[];
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// ---------------------------------------------------------------------------
|
|
@@ -216,6 +218,27 @@ ORDER BY n.nspname, t.typname;
|
|
|
216
218
|
* has an 'a'/'i' pg_depend link to its column and stays represented BY that column).
|
|
217
219
|
* These hold state, so they are base-schema: migrated, diffed, fingerprinted.
|
|
218
220
|
*/
|
|
221
|
+
/**
|
|
222
|
+
* Installed extensions — the bootstrap a declared state needs before anything else.
|
|
223
|
+
*
|
|
224
|
+
* Database-wide, not per-schema: `pg_extension` has no schema filter worth applying, and an
|
|
225
|
+
* extension installed into `public` is just as required by a model in `auth`. So this read
|
|
226
|
+
* ignores `--schema` deliberately.
|
|
227
|
+
*
|
|
228
|
+
* `plpgsql` is excluded because PostgreSQL installs it into every database by default —
|
|
229
|
+
* emitting `CREATE EXTENSION IF NOT EXISTS plpgsql` is noise, not bootstrap.
|
|
230
|
+
*
|
|
231
|
+
* Without this, a declared state that renders `field.pgType('hstore')` compiles to a CREATE
|
|
232
|
+
* TABLE whose type does not exist, and `db:check`'s from-scratch ring fails with
|
|
233
|
+
* `type "hstore" does not exist` — the state describes a database it cannot build.
|
|
234
|
+
*/
|
|
235
|
+
export const EXTENSIONS_SQL = `
|
|
236
|
+
SELECT extname AS name
|
|
237
|
+
FROM pg_extension
|
|
238
|
+
WHERE extname <> 'plpgsql'
|
|
239
|
+
ORDER BY extname;
|
|
240
|
+
`.trim();
|
|
241
|
+
|
|
219
242
|
export const SEQUENCES_SQL = `
|
|
220
243
|
SELECT
|
|
221
244
|
n.nspname AS schema,
|
|
@@ -540,6 +563,7 @@ export interface SchemaRows {
|
|
|
540
563
|
enums?: EnumRow[];
|
|
541
564
|
indexes?: IndexRow[];
|
|
542
565
|
sequences?: SequenceRow[];
|
|
566
|
+
extensions?: ExtensionRow[];
|
|
543
567
|
}
|
|
544
568
|
|
|
545
569
|
/**
|
|
@@ -618,10 +642,18 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
618
642
|
.map(sequenceRowToDescriptor)
|
|
619
643
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
620
644
|
|
|
645
|
+
// ABSENT vs EMPTY matters: a caller that did not run the extensions query leaves the key
|
|
646
|
+
// off entirely, and the renderer must not read that as "this database has none" and emit
|
|
647
|
+
// an empty list that later looks authoritative.
|
|
648
|
+
const extensions = rows.extensions === undefined
|
|
649
|
+
? undefined
|
|
650
|
+
: rows.extensions.map((r) => r.name).sort();
|
|
651
|
+
|
|
621
652
|
return {
|
|
622
653
|
tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
|
|
623
654
|
enums,
|
|
624
655
|
...(sequences.length ? { sequences } : {}),
|
|
656
|
+
...(extensions !== undefined ? { extensions } : {}),
|
|
625
657
|
};
|
|
626
658
|
}
|
|
627
659
|
|
|
@@ -631,13 +663,15 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
631
663
|
* (the ops Lambda `db:query` in production, a fake in tests) and folds the rows with
|
|
632
664
|
* `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
|
|
633
665
|
*/
|
|
666
|
+
export interface ExtensionRow { name: string }
|
|
667
|
+
|
|
634
668
|
export async function introspectSchema(session: SessionRunner): Promise<SchemaSnapshot> {
|
|
635
669
|
// ONE session, so all five queries see one database at one moment, under one pinned
|
|
636
670
|
// search_path. Column defaults, CHECK constraints and index expressions all deparse
|
|
637
671
|
// relative to that path — spread across separate connections these five rows can render
|
|
638
672
|
// the same schema two ways, and a fingerprint minted from the mix describes nothing.
|
|
639
|
-
const [columns, constraints, enums, indexes, sequences] = await session(
|
|
640
|
-
[COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL],
|
|
673
|
+
const [columns, constraints, enums, indexes, sequences, extensions] = await session(
|
|
674
|
+
[COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL],
|
|
641
675
|
INTROSPECTION_SESSION,
|
|
642
676
|
);
|
|
643
677
|
return assembleSchema({
|
|
@@ -646,5 +680,6 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
|
|
|
646
680
|
enums: enums as EnumRow[],
|
|
647
681
|
indexes: indexes as IndexRow[],
|
|
648
682
|
sequences: sequences as SequenceRow[],
|
|
683
|
+
extensions: extensions as ExtensionRow[],
|
|
649
684
|
});
|
|
650
685
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -9,17 +9,35 @@
|
|
|
9
9
|
|
|
10
10
|
import type { StorageAdapter } from './storage/index';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* These types MIRROR @everystack/server/plugin. They are not imported from it, and that
|
|
14
|
+
* is deliberate: server imports `@everystack/cli/apply`, `/reconcile`, `/exec` and more,
|
|
15
|
+
* so a type import in the other direction closes a package cycle. Both packages ship
|
|
16
|
+
* TypeScript source, so a type-only import still drags the whole module graph in.
|
|
17
|
+
*
|
|
18
|
+
* The copy going stale is what broke a consumer: server made `publishJob` optional and
|
|
19
|
+
* gave it an options argument, nothing here compared the two declarations, and `Plugin`
|
|
20
|
+
* is contravariant in `ctx` — so cli's `Plugin` silently stopped being assignable to
|
|
21
|
+
* server's and the TS2322 landed in their build instead of ours.
|
|
22
|
+
*
|
|
23
|
+
* **The link is `__tests__/cli/plugin-type-compat.test.ts`**, which imports BOTH and
|
|
24
|
+
* asserts assignability at compile time. The test can cross the boundary because it is
|
|
25
|
+
* not part of either package's published graph. Drift now fails there, in this repo, on
|
|
26
|
+
* the commit that causes it. If you change a shape below, that test is the gate.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Mirrors @everystack/server/plugin PluginContext. `publishJob` is OPTIONAL (a lean app
|
|
30
|
+
* with no @everystack/jobs never sets it) and takes an options argument. */
|
|
13
31
|
interface PluginContext {
|
|
14
32
|
db: any;
|
|
15
33
|
schema: Record<string, any>;
|
|
16
34
|
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
17
35
|
environment: string;
|
|
18
|
-
publishJob
|
|
36
|
+
publishJob?: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
|
|
19
37
|
[key: string]: unknown;
|
|
20
38
|
}
|
|
21
39
|
|
|
22
|
-
/**
|
|
40
|
+
/** Mirrors @everystack/server Route. */
|
|
23
41
|
interface Route {
|
|
24
42
|
path: string;
|
|
25
43
|
method?: string;
|
|
@@ -27,10 +45,10 @@ interface Route {
|
|
|
27
45
|
handler: (req: Request) => Promise<Response>;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
/**
|
|
48
|
+
/** Mirrors @everystack/server/plugin ActionHandler. */
|
|
31
49
|
type ActionHandler = (payload: unknown, ctx: PluginContext) => Promise<unknown>;
|
|
32
50
|
|
|
33
|
-
/**
|
|
51
|
+
/** Mirrors @everystack/server/plugin Plugin. */
|
|
34
52
|
type Plugin = (ctx: PluginContext) => Promise<{
|
|
35
53
|
routes?: Route[];
|
|
36
54
|
actions?: Record<string, ActionHandler>;
|