@everystack/cli 0.4.49 → 0.4.50
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-pull.ts +16 -2
- package/src/cli/derived-lint.ts +42 -5
- package/src/cli/derived-render.ts +29 -4
- package/src/cli/index.ts +1 -1
- 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[]> {
|
|
@@ -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
|
|
@@ -358,7 +358,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
358
358
|
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
|
|
359
359
|
if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
|
|
360
360
|
if (abilities === 'commented') {
|
|
361
|
-
|
|
361
|
+
// `live` FIRST, and named. It is the mode brownfield adoption needs — derive the authz
|
|
362
|
+
// from grants and policies the database already has — and it was advertised nowhere:
|
|
363
|
+
// not in the usage line, not in --help, not here. The only place an operator met the
|
|
364
|
+
// word was the error text for an unknown preset, so the discovery path for the right
|
|
365
|
+
// flag was to guess a wrong one. A consumer and their agent both walked past it in one
|
|
366
|
+
// session, and it cost a misdiagnosis plus a needless overwrite of 30 model files.
|
|
367
|
+
//
|
|
368
|
+
// `public-read` is deliberately no longer the headline: on an existing database it
|
|
369
|
+
// stamps can('read') on EVERY table, which on a real schema means public read of
|
|
370
|
+
// credit_cards, transactions, emails and users.
|
|
371
|
+
note(
|
|
372
|
+
`Each model scaffolds its authz decision as comments — db:check fails until every model declares. `
|
|
373
|
+
+ `For an EXISTING database, re-pull with --abilities live to derive them from the grants and policies `
|
|
374
|
+
+ `already there. --abilities public-read stamps public-read/admin-write on every table — greenfield only.`,
|
|
375
|
+
);
|
|
362
376
|
} else {
|
|
363
377
|
note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
|
|
364
378
|
}
|
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({
|
|
@@ -18,6 +18,7 @@ import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introsp
|
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
19
|
import { modelFileName, 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
|
);
|
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).
|
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>;
|