@suluk/provision 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @suluk/provision — declarative service provisioning for a Suluk app, modelled on the Open Service Broker API and
3
+ * driven like drizzle-kit (C047). Declare the infra you want in one config; each service (Cloudflare D1/KV/R2/secrets,
4
+ * Stripe, a domain, a scoped token) is an OSB {@link Broker} advertising a {@link Catalog} and answering provision /
5
+ * bind / deprovision. `plan` diffs desired-vs-live (pure), `apply` walks the binding DAG (provision → poll async
6
+ * last-operation → bind → land credentials in @suluk/env), `assertNoDrift` is the CI gate. This package is the OSB
7
+ * *client* / orchestrator — a layer ABOVE @suluk/cloudflare / @suluk/deploy / @suluk/env, which it composes as brokers +
8
+ * the binding sink. This barrel is the CORE framework; the concrete brokers + the CLI ship alongside it.
9
+ */
10
+ export type {
11
+ OperationState, ServicePlan, ServiceOffering, Catalog,
12
+ InstanceSpec, InstanceState,
13
+ ProvisionRequest, ProvisionResult, BindRequest, BindResult, OperationRequest,
14
+ Broker, BindingSink, StateStore,
15
+ } from "./types";
16
+ export { defineProvision, type ProvisionConfig } from "./config";
17
+ export { topoOrder } from "./dag";
18
+ export { parseRef, depsOf, resolveParams, stableStringify, fingerprint } from "./refs";
19
+ export { plan, type ProvisionPlan, type PlanStep, type StepAction } from "./plan";
20
+ export { apply, type ApplyOptions, type ApplyResult, type AppliedStep } from "./apply";
21
+ export { pollToDone, type PollOptions } from "./poll";
22
+ export { checkDrift, assertNoDrift, type DriftReport } from "./check";
23
+ export { pull, reconcile, discover, type PullReport, type PullEntry, type PullStatus, type DiscoveredInstance } from "./pull";
24
+ export { teardown, type TeardownOptions, type TeardownResult } from "./teardown";
25
+ // the drizzle-style snapshot + migration model (repeatable, documentable steps).
26
+ export { snapshot, EMPTY_SNAPSHOT, SNAPSHOT_VERSION, type Snapshot } from "./snapshot";
27
+ export { diffSnapshots, migrationTag, type Migration, type MigrationStep } from "./migration";
28
+ export { memoryMigrationStore, fileMigrationStore, type MigrationStore, type MigrationJournal } from "./migration-store";
29
+ export { generate } from "./generate";
30
+ export { migrate, type MigrateOptions, type MigrateResult } from "./migrate";
31
+ export { memoryStore, memorySink } from "./memory";
32
+ export { fileStore } from "./file-store";
33
+ export { envSink, type EnvSinkOptions } from "./env-sink";
34
+ // the concrete Cloudflare brokers (wrap @suluk/cloudflare's idempotent provisioners).
35
+ export { cloudflareD1, cloudflareKv, cloudflareR2, cloudflareSecrets, cloudflareToken } from "./brokers/cloudflare";
36
+ export { cloudflarePagesDomain } from "./brokers/cloudflare-domains";
37
+ // the drizzle-kit-style app config + CLI (plan / apply / check / status).
38
+ export { defineProvisionApp, type ProvisionApp } from "./app";
39
+ export { runCli, type CliResult } from "./cli";
package/src/memory.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * In-memory {@link StateStore} + {@link BindingSink} (C047) — the default seams for a dry-run, a test, or a `plan`
3
+ * preview that should touch no disk. The file-backed store + the @suluk/env sink are separate adapters; these keep the
4
+ * core dependency-free + deterministic.
5
+ */
6
+ import type { BindingSink, InstanceState, StateStore } from "./types";
7
+
8
+ /** A StateStore over an in-memory array (cloned on load/save so callers can't mutate the journal by reference). */
9
+ export function memoryStore(initial: InstanceState[] = []): StateStore & { snapshot(): InstanceState[] } {
10
+ let state: InstanceState[] = structuredClone(initial);
11
+ return {
12
+ load: () => structuredClone(state),
13
+ save: (s) => {
14
+ state = structuredClone(s);
15
+ },
16
+ snapshot: () => structuredClone(state),
17
+ };
18
+ }
19
+
20
+ /** A BindingSink that records every (envVar → value) it lands — for assertions + a dry-run "what would be set" preview. */
21
+ export function memorySink(): BindingSink & { values: Record<string, string> } {
22
+ const values: Record<string, string> = {};
23
+ return {
24
+ values,
25
+ write(outputs, mapping) {
26
+ for (const [outKey, envVar] of Object.entries(mapping)) {
27
+ if (outKey in outputs) values[envVar] = outputs[outKey];
28
+ }
29
+ },
30
+ };
31
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `migrate` (C047) — drizzle-kit's `migrate` for infrastructure. Runs the PENDING migrations (those not in this
3
+ * environment's applied-ledger) in index order, marking each applied — so the same committed history brings any
4
+ * environment (prod, preview, a fresh account) to the same state, repeatably. Each migration is executed by replaying its
5
+ * SNAPSHOT as the desired state through `apply` (pruning, so a migration's deprovision runs; `protected` still guards) —
6
+ * which REUSES the whole engine: the binding chain, async last-operation polling, the sink. The migration's step list is
7
+ * the documented record; the snapshot is what actually executes.
8
+ */
9
+ import type { Broker, BindingSink, StateStore } from "./types";
10
+ import type { MigrationStore } from "./migration-store";
11
+ import type { PollOptions } from "./poll";
12
+ import { apply } from "./apply";
13
+
14
+ export interface MigrateOptions {
15
+ brokers: Record<string, Broker>;
16
+ /** the live journal (InstanceState). */
17
+ store: StateStore;
18
+ /** the committed migrations + this env's applied-ledger. */
19
+ migrations: MigrationStore;
20
+ sink?: BindingSink;
21
+ poll?: PollOptions;
22
+ log?: (msg: string) => void;
23
+ }
24
+
25
+ export interface MigrateResult {
26
+ applied: number[];
27
+ upToDate: boolean;
28
+ }
29
+
30
+ export async function migrate(opts: MigrateOptions): Promise<MigrateResult> {
31
+ const log = opts.log ?? (() => {});
32
+ const all = await opts.migrations.listMigrations();
33
+ const done = new Set(await opts.migrations.applied());
34
+ const pending = all.filter((m) => !done.has(m.idx));
35
+ if (!pending.length) {
36
+ log("✓ migrations up to date");
37
+ return { applied: [], upToDate: true };
38
+ }
39
+ const applied: number[] = [];
40
+ for (const m of pending) {
41
+ const snap = await opts.migrations.loadSnapshot(m.idx);
42
+ if (!snap) throw new Error(`provision: migration ${m.tag} has no snapshot`);
43
+ log(`▸ ${m.tag} (${m.steps.length} step${m.steps.length === 1 ? "" : "s"})`);
44
+ // replay the snapshot as the desired state; prune so a dropped instance in this migration is deprovisioned (protected
45
+ // still guards). apply is idempotent, so a re-run after a mid-migration failure resumes cleanly.
46
+ await apply({ instances: snap.instances, pruneOrphans: true }, { brokers: opts.brokers, store: opts.store, sink: opts.sink, poll: opts.poll, log: opts.log });
47
+ await opts.migrations.markApplied(m.idx);
48
+ applied.push(m.idx);
49
+ }
50
+ return { applied, upToDate: false };
51
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The migration store (C047) — the on-disk shape drizzle-kit uses: a committed `meta/_journal.json` (the ordered list of
3
+ * migrations), one `NNNN_tag.json` per migration (the delta), one `meta/NNNN_snapshot.json` per migration (the
4
+ * point-in-time desired state), and an env-local `meta/_applied.json` (which migrations THIS environment has run — like
5
+ * drizzle's `__drizzle_migrations` table; gitignore it, prod ≠ preview). The memory impl is for tests/dry-runs.
6
+ */
7
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { EMPTY_SNAPSHOT, SNAPSHOT_VERSION, type Snapshot } from "./snapshot";
10
+ import type { Migration } from "./migration";
11
+
12
+ export interface MigrationJournal {
13
+ version: string;
14
+ entries: { idx: number; tag: string }[];
15
+ }
16
+
17
+ export interface MigrationStore {
18
+ /** the latest committed snapshot, or EMPTY when there are no migrations yet. */
19
+ lastSnapshot(): Promise<Snapshot>;
20
+ loadSnapshot(idx: number): Promise<Snapshot | null>;
21
+ /** all migrations, in index order. */
22
+ listMigrations(): Promise<Migration[]>;
23
+ /** write a new migration + its snapshot, appending the journal. */
24
+ write(migration: Migration, snapshot: Snapshot): Promise<void>;
25
+ /** which migration indices THIS environment has applied. */
26
+ applied(): Promise<number[]>;
27
+ markApplied(idx: number): Promise<void>;
28
+ }
29
+
30
+ /** An in-memory migration store (tests / dry-runs). */
31
+ export function memoryMigrationStore(): MigrationStore {
32
+ const migrations: Migration[] = [];
33
+ const snapshots = new Map<number, Snapshot>();
34
+ const appliedSet: number[] = [];
35
+ return {
36
+ async lastSnapshot() {
37
+ const last = migrations.at(-1);
38
+ return last ? snapshots.get(last.idx) ?? EMPTY_SNAPSHOT : EMPTY_SNAPSHOT;
39
+ },
40
+ async loadSnapshot(idx) {
41
+ return snapshots.get(idx) ?? null;
42
+ },
43
+ async listMigrations() {
44
+ return migrations.map((m) => ({ ...m }));
45
+ },
46
+ async write(migration, snap) {
47
+ migrations.push(migration);
48
+ snapshots.set(snap.idx, snap);
49
+ },
50
+ async applied() {
51
+ return [...appliedSet];
52
+ },
53
+ async markApplied(idx) {
54
+ if (!appliedSet.includes(idx)) appliedSet.push(idx);
55
+ },
56
+ };
57
+ }
58
+
59
+ /** A file-backed migration store rooted at `dir` (default `provision/`). Commits `NNNN_tag.json` + `meta/_journal.json` +
60
+ * `meta/NNNN_snapshot.json`; keeps the env-local `meta/_applied.json` (gitignore it). */
61
+ export function fileMigrationStore(dir = "provision"): MigrationStore {
62
+ const meta = join(dir, "meta");
63
+ const journalPath = join(meta, "_journal.json");
64
+ const appliedPath = join(meta, "_applied.json");
65
+ const pretty = (v: unknown) => `${JSON.stringify(v, null, 2)}\n`;
66
+
67
+ async function readJson<T>(path: string, fallback: T): Promise<T> {
68
+ try {
69
+ return JSON.parse(await readFile(path, "utf8")) as T;
70
+ } catch (e) {
71
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback;
72
+ throw e;
73
+ }
74
+ }
75
+ const pad = (idx: number) => String(idx).padStart(4, "0");
76
+ const journal = () => readJson<MigrationJournal>(journalPath, { version: SNAPSHOT_VERSION, entries: [] });
77
+ const loadSnapshot = (idx: number) => readJson<Snapshot | null>(join(meta, `${pad(idx)}_snapshot.json`), null);
78
+
79
+ return {
80
+ async lastSnapshot() {
81
+ const j = await journal();
82
+ const last = j.entries.at(-1);
83
+ return last ? (await loadSnapshot(last.idx)) ?? EMPTY_SNAPSHOT : EMPTY_SNAPSHOT;
84
+ },
85
+ loadSnapshot,
86
+ async listMigrations() {
87
+ const j = await journal();
88
+ return Promise.all(j.entries.map((e) => readJson<Migration>(join(dir, `${e.tag}.json`), { idx: e.idx, tag: e.tag, steps: [] })));
89
+ },
90
+ async write(migration, snap) {
91
+ await mkdir(meta, { recursive: true });
92
+ await writeFile(join(dir, `${migration.tag}.json`), pretty(migration));
93
+ await writeFile(join(meta, `${pad(snap.idx)}_snapshot.json`), pretty(snap));
94
+ const j = await journal();
95
+ j.entries.push({ idx: migration.idx, tag: migration.tag });
96
+ await writeFile(journalPath, pretty(j));
97
+ },
98
+ async applied() {
99
+ return readJson<number[]>(appliedPath, []);
100
+ },
101
+ async markApplied(idx) {
102
+ await mkdir(meta, { recursive: true });
103
+ const a = await readJson<number[]>(appliedPath, []);
104
+ if (!a.includes(idx)) {
105
+ a.push(idx);
106
+ await writeFile(appliedPath, pretty(a));
107
+ }
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Migrations (C047) — drizzle-kit's `NNNN_name.sql` for infrastructure: the DELTA between two snapshots, as an ordered,
3
+ * human-readable list of steps (create/update/deprovision). A migration is documentable (committed JSON, reviewable in a
4
+ * PR) and repeatable (its snapshot replays to the same state). `diffSnapshots` computes it: creates/updates in
5
+ * binding-DAG order (a producer before its consumer), deprovisions in reverse (a consumer before its producer).
6
+ */
7
+ import type { InstanceSpec } from "./types";
8
+ import type { ProvisionConfig } from "./config";
9
+ import type { Snapshot } from "./snapshot";
10
+ import { topoOrder } from "./dag";
11
+ import { fingerprint } from "./refs";
12
+
13
+ export interface MigrationStep {
14
+ action: "create" | "update" | "deprovision";
15
+ ref: string;
16
+ service: string;
17
+ name: string;
18
+ /** the full spec for a create/update (so the migration is self-describing); absent for a deprovision. */
19
+ spec?: InstanceSpec;
20
+ }
21
+
22
+ export interface Migration {
23
+ idx: number;
24
+ /** the file stem, e.g. "0000_initial". */
25
+ tag: string;
26
+ steps: MigrationStep[];
27
+ }
28
+
29
+ /** `NNNN_name` — the zero-padded migration tag. */
30
+ export const migrationTag = (idx: number, name = "migration"): string => `${String(idx).padStart(4, "0")}_${name}`;
31
+
32
+ /** Diff the previous snapshot against the next (current) config → the ordered migration steps. Creates + updates come in
33
+ * binding-DAG order (producers first); deprovisions of dropped instances come last, in reverse (consumers first). */
34
+ export function diffSnapshots(prev: Snapshot, next: ProvisionConfig): MigrationStep[] {
35
+ const prevByRef = new Map(prev.instances.map((i) => [i.ref, i]));
36
+ const nextByRef = new Map(next.instances.map((i) => [i.ref, i]));
37
+ const steps: MigrationStep[] = [];
38
+
39
+ for (const spec of topoOrder(next.instances)) {
40
+ const before = prevByRef.get(spec.ref);
41
+ if (!before) steps.push({ action: "create", ref: spec.ref, service: spec.service, name: spec.name, spec });
42
+ else if (fingerprint(before) !== fingerprint(spec) || before.name !== spec.name) steps.push({ action: "update", ref: spec.ref, service: spec.service, name: spec.name, spec });
43
+ }
44
+ for (const prevSpec of [...prev.instances].reverse()) {
45
+ if (!nextByRef.has(prevSpec.ref)) steps.push({ action: "deprovision", ref: prevSpec.ref, service: prevSpec.service, name: prevSpec.name });
46
+ }
47
+ return steps;
48
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The diff (C047) — drizzle-kit's `generate`/diff for infrastructure: desired config × live state → a reviewable plan of
3
+ * steps, in binding-DAG order. PURE: no provider calls, no clock. `apply` executes a plan; `check` asserts a plan is
4
+ * empty (no drift). The four actions mirror the OSB verbs the executor will call.
5
+ */
6
+ import type { InstanceSpec, InstanceState } from "./types";
7
+ import type { ProvisionConfig } from "./config";
8
+ import { topoOrder } from "./dag";
9
+ import { fingerprint } from "./refs";
10
+
11
+ export type StepAction = "create" | "update" | "noop" | "deprovision";
12
+
13
+ export interface PlanStep {
14
+ ref: string;
15
+ service: string;
16
+ name: string;
17
+ action: StepAction;
18
+ /** human-readable cause: "new" | "params changed" | "up to date" | "orphan (in state, not in config)". */
19
+ reason: string;
20
+ }
21
+
22
+ export interface ProvisionPlan {
23
+ steps: PlanStep[];
24
+ /** refs present in state but absent from config — deprovisioned only when pruning is on (else surfaced, not touched). */
25
+ orphans: string[];
26
+ /** true when every step is a noop and there are no (prunable) orphans — the `check` CI gate passes on this. */
27
+ clean: boolean;
28
+ }
29
+
30
+ /** Diff `config` against `state`. Desired instances are emitted in binding-DAG order (create/update/noop); orphans
31
+ * (state − config) become `deprovision` steps only when `prune` (the config default, or an override) is on. */
32
+ export function plan(config: ProvisionConfig, state: InstanceState[], prune = config.pruneOrphans ?? false): ProvisionPlan {
33
+ const byRef = new Map(state.map((s) => [s.ref, s]));
34
+ const declared = new Set(config.instances.map((i) => i.ref));
35
+ const ordered = topoOrder(config.instances);
36
+
37
+ const steps: PlanStep[] = ordered.map((spec: InstanceSpec) => {
38
+ const prior = byRef.get(spec.ref);
39
+ if (!prior) return { ref: spec.ref, service: spec.service, name: spec.name, action: "create", reason: "new" };
40
+ if (prior.fingerprint !== fingerprint(spec) || prior.name !== spec.name) {
41
+ return { ref: spec.ref, service: spec.service, name: spec.name, action: "update", reason: "params changed" };
42
+ }
43
+ return { ref: spec.ref, service: spec.service, name: spec.name, action: "noop", reason: "up to date" };
44
+ });
45
+
46
+ const orphans = state.filter((s) => !declared.has(s.ref)).map((s) => s.ref);
47
+ if (prune) {
48
+ for (const ref of orphans) {
49
+ const s = byRef.get(ref)!;
50
+ // a PROTECTED orphan is surfaced but NOT scheduled for destruction — the prevent-destroy safety rail.
51
+ if (s.protected) steps.push({ ref, service: s.service, name: s.name, action: "noop", reason: "orphan but protected — kept" });
52
+ else steps.push({ ref, service: s.service, name: s.name, action: "deprovision", reason: "orphan (in state, not in config)" });
53
+ }
54
+ }
55
+
56
+ // "clean" ignores protected-orphan noops (they're intentionally kept, not pending work).
57
+ const prunable = prune ? orphans.filter((ref) => !byRef.get(ref)?.protected) : [];
58
+ const clean = steps.every((s) => s.action === "noop") && prunable.length === 0;
59
+ return { steps, orphans, clean };
60
+ }
package/src/poll.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * OSB last-operation polling (C047) — shared by `apply` (async provision) and `teardown` (async deprovision). The clock +
3
+ * sleep are injected so the loop is deterministically testable. Throws on "failed" or timeout.
4
+ */
5
+ import type { Broker, OperationState } from "./types";
6
+
7
+ export interface PollOptions {
8
+ intervalMs?: number;
9
+ timeoutMs?: number;
10
+ sleep?: (ms: number) => Promise<void>;
11
+ now?: () => number;
12
+ }
13
+
14
+ const sleepReal = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
15
+
16
+ /** Poll an async operation to a terminal state. Throws on "failed" or after `timeoutMs`. */
17
+ export async function pollToDone(
18
+ broker: Broker,
19
+ req: { ref: string; name: string; instanceId?: string; operation: string },
20
+ poll: PollOptions,
21
+ log: (m: string) => void,
22
+ ): Promise<void> {
23
+ if (!broker.lastOperation) throw new Error(`provision: ${req.ref} returned an async operation but its broker has no lastOperation()`);
24
+ const intervalMs = poll.intervalMs ?? 2000;
25
+ const timeoutMs = poll.timeoutMs ?? 600_000;
26
+ const now = poll.now ?? Date.now;
27
+ const sleep = poll.sleep ?? sleepReal;
28
+ const start = now();
29
+ for (;;) {
30
+ const { state, description }: { state: OperationState; description?: string } = await broker.lastOperation(req);
31
+ if (state === "succeeded") return;
32
+ if (state === "failed") throw new Error(`provision: ${req.ref} operation failed${description ? ` — ${description}` : ""}`);
33
+ if (now() - start > timeoutMs) throw new Error(`provision: ${req.ref} operation timed out after ${timeoutMs}ms`);
34
+ log(` … ${req.ref} ${req.operation} (in progress)`);
35
+ await sleep(intervalMs);
36
+ }
37
+ }
package/src/pull.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Introspection (C047) — drizzle-kit's `pull` for infrastructure, over OSB's "Fetching a Service Instance". `plan`
3
+ * compares the config to the JOURNAL (what we believe is live); `pull` compares the journal to the PROVIDER (what's
4
+ * actually live), catching EXTERNAL drift — a database dropped in the dashboard, a token revoked out of band, an id that
5
+ * changed behind the config's back. `reconcile` folds a report back into the journal; `discover` finds untracked
6
+ * resources to adopt. All read-only against the journal — the caller persists the reconciled state.
7
+ */
8
+ import type { Broker, InstanceState, OperationRequest } from "./types";
9
+
10
+ export type PullStatus = "live" | "missing" | "drifted" | "unknown";
11
+
12
+ export interface PullEntry {
13
+ ref: string;
14
+ service: string;
15
+ name: string;
16
+ instanceId: string;
17
+ /** live = present + matches · missing = gone from the provider · drifted = present but outputs changed · unknown = the
18
+ * broker has no `fetch`, so we couldn't check. */
19
+ status: PullStatus;
20
+ liveOutputs?: Record<string, string>;
21
+ }
22
+
23
+ export interface PullReport {
24
+ entries: PullEntry[];
25
+ /** journaled refs whose live resource is GONE (deleted outside the config) — the next `apply` re-creates them. */
26
+ missing: string[];
27
+ /** journaled refs whose live outputs differ from the journal. */
28
+ drifted: string[];
29
+ /** nothing missing or drifted (unknowns don't count — we couldn't verify them). */
30
+ clean: boolean;
31
+ }
32
+
33
+ /** Compare only the keys the live fetch reported (it may return a subset of the journal's outputs). */
34
+ function outputsMatch(live: Record<string, string>, journal: Record<string, string>): boolean {
35
+ return Object.keys(live).every((k) => live[k] === journal[k]);
36
+ }
37
+
38
+ /** Fetch each journaled instance's live state via its broker (OSB fetch) → an external-drift report. Read-only. */
39
+ export async function pull(state: InstanceState[], brokers: Record<string, Broker>): Promise<PullReport> {
40
+ const entries: PullEntry[] = [];
41
+ for (const s of state) {
42
+ const broker = brokers[s.service];
43
+ const base = { ref: s.ref, service: s.service, name: s.name, instanceId: s.instanceId };
44
+ if (!broker?.fetch) {
45
+ entries.push({ ...base, status: "unknown" });
46
+ continue;
47
+ }
48
+ const req: OperationRequest = { ref: s.ref, name: s.name, instanceId: s.instanceId, operation: "fetch" };
49
+ const live = await broker.fetch(req);
50
+ if (!live.exists) {
51
+ entries.push({ ...base, status: "missing" });
52
+ continue;
53
+ }
54
+ const drifted = !!live.outputs && !outputsMatch(live.outputs, s.outputs);
55
+ entries.push({ ...base, status: drifted ? "drifted" : "live", liveOutputs: live.outputs });
56
+ }
57
+ const missing = entries.filter((e) => e.status === "missing").map((e) => e.ref);
58
+ const drifted = entries.filter((e) => e.status === "drifted").map((e) => e.ref);
59
+ return { entries, missing, drifted, clean: missing.length === 0 && drifted.length === 0 };
60
+ }
61
+
62
+ /** Fold a pull report into the journal: DROP externally-deleted instances (so the next `apply` re-creates them) + MERGE
63
+ * live outputs over drifted ones (never dropping a bound value the provider doesn't know, e.g. a minted token). Pure —
64
+ * returns the reconciled state; the caller saves it. */
65
+ export function reconcile(state: InstanceState[], report: PullReport): InstanceState[] {
66
+ const missing = new Set(report.missing);
67
+ const liveByRef = new Map(report.entries.filter((e) => e.liveOutputs).map((e) => [e.ref, e.liveOutputs!]));
68
+ return state
69
+ .filter((s) => !missing.has(s.ref))
70
+ .map((s) => (liveByRef.has(s.ref) ? { ...s, outputs: { ...s.outputs, ...liveByRef.get(s.ref)! } } : s));
71
+ }
72
+
73
+ export interface DiscoveredInstance {
74
+ service: string;
75
+ name: string;
76
+ instanceId: string;
77
+ outputs?: Record<string, string>;
78
+ }
79
+
80
+ /** Discover live instances (via `broker.list`) that AREN'T in the journal — untracked resources to adopt (`pull
81
+ * --discover`). Skips services whose broker has no `list`. */
82
+ export async function discover(state: InstanceState[], brokers: Record<string, Broker>): Promise<DiscoveredInstance[]> {
83
+ const known = new Set(state.map((s) => `${s.service}:${s.instanceId}`));
84
+ const found: DiscoveredInstance[] = [];
85
+ for (const [service, broker] of Object.entries(brokers)) {
86
+ if (!broker.list) continue;
87
+ for (const inst of await broker.list()) {
88
+ if (!known.has(`${service}:${inst.instanceId}`)) found.push({ service, name: inst.name, instanceId: inst.instanceId, outputs: inst.outputs });
89
+ }
90
+ }
91
+ return found;
92
+ }
package/src/refs.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Binding references + fingerprints (C047) — the wiring under the provisioning DAG. A param string of the form
3
+ * `@<ref>.<key>` names another instance's binding OUTPUT (e.g. `@db.database_id`): it's an EDGE (this instance depends on
4
+ * `db`) and a substitution (resolved from `db`'s outputs at apply time). Pure + deterministic.
5
+ */
6
+ import type { InstanceSpec } from "./types";
7
+
8
+ /** Matches a whole-string binding reference `@<ref>.<key>` (refs are dotted-handle → output-key). */
9
+ const REF_RE = /^@([A-Za-z0-9_-]+)\.([A-Za-z0-9_.-]+)$/;
10
+
11
+ /** Parse a single value: a `@ref.key` string → its parts, else null (not a reference). */
12
+ export function parseRef(value: unknown): { ref: string; key: string } | null {
13
+ if (typeof value !== "string") return null;
14
+ const m = REF_RE.exec(value);
15
+ return m ? { ref: m[1], key: m[2] } : null;
16
+ }
17
+
18
+ /** Every instance ref a spec's params depend on (deduped) — the spec's in-edges in the DAG. */
19
+ export function depsOf(spec: InstanceSpec): string[] {
20
+ const out = new Set<string>();
21
+ for (const v of Object.values(spec.params ?? {})) {
22
+ const r = parseRef(v);
23
+ if (r) out.add(r.ref);
24
+ }
25
+ return [...out];
26
+ }
27
+
28
+ /** Resolve a spec's params against the accumulated outputs (ref → its output map). Throws if a referenced output is
29
+ * missing (a producer that didn't emit the key) — fail-closed, never silently substitute undefined into a provider call. */
30
+ export function resolveParams(spec: InstanceSpec, outputsByRef: Record<string, Record<string, string>>): Record<string, unknown> {
31
+ const out: Record<string, unknown> = {};
32
+ for (const [k, v] of Object.entries(spec.params ?? {})) {
33
+ const r = parseRef(v);
34
+ if (!r) {
35
+ out[k] = v;
36
+ continue;
37
+ }
38
+ const producer = outputsByRef[r.ref];
39
+ if (!producer || !(r.key in producer)) {
40
+ throw new Error(`provision: ${spec.ref}.params.${k} references @${r.ref}.${r.key}, but ${r.ref} has no output "${r.key}"`);
41
+ }
42
+ out[k] = producer[r.key];
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** A stable JSON string (recursively sorted keys) — order-independent so a fingerprint is reproducible. */
48
+ export function stableStringify(value: unknown): string {
49
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
50
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
51
+ const keys = Object.keys(value as Record<string, unknown>).sort();
52
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(",")}}`;
53
+ }
54
+
55
+ /** The drift fingerprint of a desired instance = a stable hash of (name + plan + params). A change flips it → an `update`
56
+ * step; an unchanged spec matches its stored fingerprint → a `noop`. (Refs are fingerprinted as their literal `@ref.key`
57
+ * text — a producer's VALUE changing is the producer's own drift, surfaced on its own step.) */
58
+ export function fingerprint(spec: InstanceSpec): string {
59
+ return stableStringify({ name: spec.name, plan: spec.plan ?? null, params: spec.params ?? {} });
60
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Snapshots (C047) — drizzle-kit's `meta/NNNN_snapshot.json` for infrastructure. A snapshot is the point-in-time DESIRED
3
+ * state (the config's instances) at a given migration index. `generate` diffs the last snapshot against the current
4
+ * config to compute the next migration; `migrate` replays a snapshot as the desired state. Committed to git, snapshots
5
+ * make every infra change a repeatable, reviewable record — the same discipline as SQL migration snapshots.
6
+ */
7
+ import type { InstanceSpec } from "./types";
8
+ import type { ProvisionConfig } from "./config";
9
+
10
+ export const SNAPSHOT_VERSION = "1";
11
+
12
+ export interface Snapshot {
13
+ version: string;
14
+ /** the migration index this snapshot represents (−1 = the empty pre-history state). */
15
+ idx: number;
16
+ instances: InstanceSpec[];
17
+ }
18
+
19
+ /** The empty baseline — the "before the first migration" state, so the first `generate` diffs against nothing. */
20
+ export const EMPTY_SNAPSHOT: Snapshot = { version: SNAPSHOT_VERSION, idx: -1, instances: [] };
21
+
22
+ /** A snapshot of `config` at migration `idx`. */
23
+ export function snapshot(idx: number, config: ProvisionConfig): Snapshot {
24
+ return { version: SNAPSHOT_VERSION, idx, instances: config.instances };
25
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Teardown (C047) — the destructive op: deprovision EVERY journaled instance, CONSUMERS-FIRST (reverse of the journal's
3
+ * provision order), so a producer is never removed while a consumer still references it. The careful rails: a `protected`
4
+ * instance (a database, a bucket — a resource whose loss is unrecoverable) is SKIPPED unless `force`; a broker with no
5
+ * `deprovision` is skipped (kept); an async teardown is polled to done. The CLI additionally gates this behind an explicit
6
+ * confirmation — `teardown` alone previews, `teardown --yes` executes. Returns what was torn down + what was kept.
7
+ */
8
+ import type { Broker, InstanceState, StateStore } from "./types";
9
+ import { pollToDone, type PollOptions } from "./poll";
10
+
11
+ export interface TeardownOptions {
12
+ brokers: Record<string, Broker>;
13
+ store: StateStore;
14
+ /** override the `protected` rail — required to destroy a protected instance. */
15
+ force?: boolean;
16
+ /** preview only: compute the order + honour the rails, but call NO provider + don't save. The confirmation default. */
17
+ dryRun?: boolean;
18
+ log?: (msg: string) => void;
19
+ poll?: PollOptions;
20
+ }
21
+
22
+ export interface TeardownResult {
23
+ /** refs deprovisioned (or, under dryRun, that WOULD be). */
24
+ torn: string[];
25
+ /** refs kept + why: protected (no force) or the broker can't deprovision. */
26
+ kept: { ref: string; reason: string }[];
27
+ /** the remaining journal after teardown (the kept instances). */
28
+ state: InstanceState[];
29
+ }
30
+
31
+ /** Deprovision the whole journal, consumers-first, honouring `protected`. Destructive — gate it behind confirmation. */
32
+ export async function teardown(opts: TeardownOptions): Promise<TeardownResult> {
33
+ const log = opts.log ?? (() => {});
34
+ const poll = opts.poll ?? {};
35
+ const state = await opts.store.load();
36
+ const torn: string[] = [];
37
+ const kept: { ref: string; reason: string }[] = [];
38
+ const keptState: InstanceState[] = [];
39
+
40
+ // reverse journal order ≈ consumers before producers (the journal is written in provision/DAG order).
41
+ for (const s of [...state].reverse()) {
42
+ if (s.protected && !opts.force) {
43
+ kept.push({ ref: s.ref, reason: "protected" });
44
+ keptState.push(s);
45
+ log(`• kept ${s.ref} (protected — pass --force to destroy)`);
46
+ continue;
47
+ }
48
+ const broker = opts.brokers[s.service];
49
+ if (!broker?.deprovision) {
50
+ kept.push({ ref: s.ref, reason: "no deprovision" });
51
+ keptState.push(s);
52
+ log(`• kept ${s.ref} (${s.service} has no deprovision)`);
53
+ continue;
54
+ }
55
+ if (opts.dryRun) {
56
+ torn.push(s.ref);
57
+ log(` - would tear down ${s.ref} (${s.name})`);
58
+ continue;
59
+ }
60
+ const res = await broker.deprovision({ ref: s.ref, name: s.name, instanceId: s.instanceId, operation: "deprovision" });
61
+ if (res.state === "in progress") await pollToDone(broker, { ref: s.ref, name: s.name, instanceId: s.instanceId, operation: res.operation ?? "deprovision" }, poll, log);
62
+ torn.push(s.ref);
63
+ log(`✗ torn down ${s.ref} (${s.name})`);
64
+ }
65
+
66
+ const finalState = keptState.reverse(); // restore journal (provision) order
67
+ if (!opts.dryRun) await opts.store.save(finalState);
68
+ return { torn, kept, state: finalState };
69
+ }