@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.
@@ -0,0 +1,169 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import {
3
+ defineProvision, apply, plan, topoOrder, resolveParams, assertNoDrift, checkDrift,
4
+ memoryStore, memorySink, type Broker, type ProvisionConfig, type InstanceState,
5
+ } from "../src/index";
6
+
7
+ /**
8
+ * C047 — the OSB provisioning framework, witnessed with MOCK brokers through the full lifecycle: catalog → provision
9
+ * (sync + async-with-polling) → bind → the BINDING CHAIN (a downstream `@ref.key` param resolving to a freshly
10
+ * provisioned value) → idempotent re-apply → drift-update → orphan deprovision. No real provider; the clock + sleep are
11
+ * injected so polling is deterministic.
12
+ */
13
+ type Calls = { provision: string[]; bind: string[]; deprovision: string[]; lastOp: number };
14
+ function mockBroker(id: string, opts: { async?: boolean; bindable?: boolean; out?: (name: string) => Record<string, string> } = {}): Broker & { calls: Calls } {
15
+ const calls: Calls = { provision: [], bind: [], deprovision: [], lastOp: 0 };
16
+ let opState = 0;
17
+ return {
18
+ calls,
19
+ catalog: () => ({ services: [{ id, name: id, description: id, bindable: !!opts.bindable, plans: [{ id: "standard", name: "Standard" }] }] }),
20
+ async provision(req) {
21
+ calls.provision.push(req.ref);
22
+ const instanceId = `${id}:${req.name}`;
23
+ if (opts.async) {
24
+ opState = 0;
25
+ return { state: "in progress", operation: `op:${req.name}`, instanceId, outputs: opts.bindable ? {} : (opts.out?.(req.name) ?? {}) };
26
+ }
27
+ return { state: "succeeded", instanceId, outputs: opts.bindable ? {} : (opts.out?.(req.name) ?? {}) };
28
+ },
29
+ lastOperation: opts.async
30
+ ? async () => {
31
+ calls.lastOp++;
32
+ opState++;
33
+ return { state: opState >= 2 ? "succeeded" : "in progress" }; // "in progress" once, then "succeeded"
34
+ }
35
+ : undefined,
36
+ bind: opts.bindable
37
+ ? async (req) => {
38
+ calls.bind.push(req.ref);
39
+ return { outputs: opts.out?.(req.name) ?? { token: `${req.name}-tok` } };
40
+ }
41
+ : undefined,
42
+ async deprovision(req) {
43
+ calls.deprovision.push(req.ref);
44
+ return { state: "succeeded" };
45
+ },
46
+ };
47
+ }
48
+
49
+ /** db (async; its provision emits database_id) → token (sync, bindable; its scope param references @db.database_id). */
50
+ function fixture() {
51
+ const d1 = mockBroker("mock-d1", { async: true, out: (name) => ({ database_id: `${name}-uuid` }) });
52
+ const token = mockBroker("mock-token", { bindable: true, out: (name) => ({ token: `${name}-secret` }) });
53
+ const config: ProvisionConfig = {
54
+ instances: [
55
+ { ref: "token", service: "mock-token", name: "d1-token", params: { scope: "@db.database_id" }, bind: { token: "CLOUDFLARE_D1_TOKEN" } },
56
+ { ref: "db", service: "mock-d1", name: "app-db", bind: { database_id: "CLOUDFLARE_D1_ID" } },
57
+ ],
58
+ };
59
+ return { d1, token, config, brokers: { "mock-d1": d1, "mock-token": token } };
60
+ }
61
+ const fastPoll = { sleep: async () => {}, intervalMs: 0 };
62
+
63
+ describe("the binding DAG", () => {
64
+ test("topoOrder puts a producer before its consumer regardless of config order", () => {
65
+ const { config } = fixture();
66
+ expect(topoOrder(config.instances).map((i) => i.ref)).toEqual(["db", "token"]); // token declared first, but depends on db
67
+ });
68
+
69
+ test("defineProvision rejects a duplicate ref, an unknown ref, and a cycle", () => {
70
+ expect(() => defineProvision({ instances: [{ ref: "a", service: "s", name: "a" }, { ref: "a", service: "s", name: "a2" }] })).toThrow(/duplicate/);
71
+ expect(() => defineProvision({ instances: [{ ref: "a", service: "s", name: "a", params: { x: "@ghost.id" } }] })).toThrow(/no instance "ghost"/);
72
+ expect(() => defineProvision({ instances: [
73
+ { ref: "a", service: "s", name: "a", params: { x: "@b.id" } },
74
+ { ref: "b", service: "s", name: "b", params: { y: "@a.id" } },
75
+ ] })).toThrow(/cycle/);
76
+ });
77
+
78
+ test("resolveParams substitutes a ref and fails closed on a missing output", () => {
79
+ const spec = { ref: "c", service: "s", name: "c", params: { scope: "@db.database_id", literal: 7 } };
80
+ expect(resolveParams(spec, { db: { database_id: "abc" } })).toEqual({ scope: "abc", literal: 7 });
81
+ expect(() => resolveParams(spec, { db: {} })).toThrow(/no output "database_id"/);
82
+ });
83
+ });
84
+
85
+ describe("apply — the full OSB lifecycle", () => {
86
+ test("provisions in DAG order, polls the async op, resolves the binding chain, lands credentials in the sink", async () => {
87
+ const { config, brokers, d1, token } = fixture();
88
+ const store = memoryStore();
89
+ const sink = memorySink();
90
+ const res = await apply(config, { brokers, store, sink, poll: fastPoll });
91
+
92
+ // order: db before token; db was async (polled twice: "in progress" → "succeeded").
93
+ expect(res.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:db", "create:token"]);
94
+ expect(d1.calls.lastOp).toBe(2);
95
+ // the binding chain: token's scope param resolved to db's freshly provisioned database_id.
96
+ expect(token.calls.provision).toEqual(["token"]);
97
+ expect(res.outputsByRef.db).toEqual({ database_id: "app-db-uuid" });
98
+ // the sink got both mapped env vars.
99
+ expect(sink.values).toEqual({ CLOUDFLARE_D1_ID: "app-db-uuid", CLOUDFLARE_D1_TOKEN: "d1-token-secret" });
100
+ // state persisted for both.
101
+ expect(store.snapshot().map((s) => s.ref).sort()).toEqual(["db", "token"]);
102
+ });
103
+
104
+ test("re-applying a settled config is ALL noops — touches no provider", async () => {
105
+ const { config, brokers, d1, token } = fixture();
106
+ const store = memoryStore();
107
+ await apply(config, { brokers, store, poll: fastPoll });
108
+ d1.calls.provision.length = 0;
109
+ token.calls.provision.length = 0;
110
+ const again = await apply(config, { brokers, store, poll: fastPoll });
111
+ expect(again.steps.every((s) => s.action === "noop")).toBe(true);
112
+ expect(d1.calls.provision).toEqual([]); // idempotent — no provider call on a clean re-apply
113
+ expect(token.calls.provision).toEqual([]);
114
+ });
115
+
116
+ test("a param change re-provisions ONLY the drifted instance (the producer stays noop)", async () => {
117
+ const { config, brokers, d1, token } = fixture();
118
+ const store = memoryStore();
119
+ await apply(config, { brokers, store, poll: fastPoll });
120
+ d1.calls.provision.length = 0;
121
+ token.calls.provision.length = 0;
122
+ // drift the token's params.
123
+ const drifted: ProvisionConfig = { instances: config.instances.map((i) => (i.ref === "token" ? { ...i, params: { scope: "@db.database_id", ttl: 3600 } } : i)) };
124
+ const res = await apply(drifted, { brokers, store, poll: fastPoll });
125
+ expect(res.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["noop:db", "update:token"]);
126
+ expect(d1.calls.provision).toEqual([]); // db unchanged
127
+ expect(token.calls.provision).toEqual(["token"]); // only the drifted one re-provisioned
128
+ });
129
+
130
+ test("orphan mitigation: an instance dropped from config is deprovisioned under --prune", async () => {
131
+ const { config, brokers, token } = fixture();
132
+ const store = memoryStore();
133
+ await apply(config, { brokers, store, poll: fastPoll });
134
+ const dbOnly: ProvisionConfig = { instances: config.instances.filter((i) => i.ref === "db") };
135
+ const res = await apply(dbOnly, { brokers, store, prune: true, poll: fastPoll });
136
+ expect(res.steps.find((s) => s.ref === "token")?.action).toBe("deprovision");
137
+ expect(token.calls.deprovision).toEqual(["token"]);
138
+ expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]);
139
+ });
140
+
141
+ test("WITHOUT --prune an orphan is surfaced but NOT torn down", async () => {
142
+ const { config, brokers, token } = fixture();
143
+ const store = memoryStore();
144
+ await apply(config, { brokers, store, poll: fastPoll });
145
+ const dbOnly: ProvisionConfig = { instances: config.instances.filter((i) => i.ref === "db") };
146
+ const res = await apply(dbOnly, { brokers, store, poll: fastPoll }); // prune off
147
+ expect(token.calls.deprovision).toEqual([]); // destructive op never fires by default
148
+ expect(res.state.map((s) => s.ref).sort()).toEqual(["db", "token"]); // token state kept
149
+ });
150
+ });
151
+
152
+ describe("plan + the drift gate", () => {
153
+ test("plan marks new instances create; checkDrift/assertNoDrift gate on pending work", () => {
154
+ const { config } = fixture();
155
+ const empty: InstanceState[] = [];
156
+ const p = plan(config, empty);
157
+ expect(p.clean).toBe(false);
158
+ expect(p.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:db", "create:token"]);
159
+ expect(checkDrift(config, empty).drift.length).toBe(2);
160
+ expect(() => assertNoDrift(config, empty)).toThrow(/drift detected/);
161
+ });
162
+
163
+ test("assertNoDrift passes once everything is provisioned", async () => {
164
+ const { config, brokers } = fixture();
165
+ const store = memoryStore();
166
+ await apply(config, { brokers, store, poll: fastPoll });
167
+ expect(() => assertNoDrift(config, store.snapshot())).not.toThrow();
168
+ });
169
+ });
@@ -0,0 +1,130 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ diffSnapshots, generate, migrate, EMPTY_SNAPSHOT,
7
+ memoryMigrationStore, fileMigrationStore, memoryStore, memorySink, runCli, defineProvisionApp,
8
+ type Broker, type InstanceSpec,
9
+ } from "../src/index";
10
+
11
+ /**
12
+ * C047 build #6 — the snapshot + migration model (repeatable, documentable steps like drizzle-kit). `generate` records a
13
+ * migration + snapshot from the config delta; `migrate` replays PENDING migrations in order via the engine.
14
+ */
15
+ const DB: InstanceSpec = { ref: "db", service: "d1", name: "app-db" };
16
+ const KV: InstanceSpec = { ref: "kv", service: "kv", name: "sessions", params: { db: "@db.database_id" } }; // depends on db
17
+
18
+ function mkBroker(id: string): Broker & { provisioned: string[]; deprovisioned: string[] } {
19
+ const provisioned: string[] = [];
20
+ const deprovisioned: string[] = [];
21
+ return {
22
+ provisioned,
23
+ deprovisioned,
24
+ catalog: () => ({ services: [{ id, name: id, description: id, bindable: false, plans: [{ id: "standard", name: "S" }] }] }),
25
+ async provision(req) {
26
+ provisioned.push(req.ref);
27
+ return { state: "succeeded", instanceId: `${id}:${req.name}`, outputs: { database_id: `${req.name}-uuid` } };
28
+ },
29
+ async deprovision(req) {
30
+ deprovisioned.push(req.ref);
31
+ return { state: "succeeded" };
32
+ },
33
+ };
34
+ }
35
+ const mk = () => {
36
+ const d1 = mkBroker("d1");
37
+ const kv = mkBroker("kv");
38
+ return { d1, kv, brokers: { d1, kv } };
39
+ };
40
+
41
+ describe("diffSnapshots — the delta between snapshots", () => {
42
+ test("empty → creates in DAG order; a changed instance → update; a dropped one → deprovision; unchanged → nothing", () => {
43
+ const creates = diffSnapshots(EMPTY_SNAPSHOT, { instances: [KV, DB] }); // KV declared first but depends on DB
44
+ expect(creates.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:db", "create:kv"]);
45
+
46
+ const prev = { version: "1", idx: 0, instances: [DB, KV] };
47
+ expect(diffSnapshots(prev, { instances: [DB, KV] })).toEqual([]); // unchanged
48
+ expect(diffSnapshots(prev, { instances: [{ ...DB, name: "app-db-2" }, KV] }).map((s) => s.action)).toEqual(["update"]);
49
+ expect(diffSnapshots(prev, { instances: [DB] }).map((s) => `${s.action}:${s.ref}`)).toEqual(["deprovision:kv"]); // dropped
50
+ });
51
+ });
52
+
53
+ describe("generate — record the delta as a migration + snapshot", () => {
54
+ test("first generate writes 0000; a no-op generate returns null; a change writes the next delta only", async () => {
55
+ const m = memoryMigrationStore();
56
+ const m0 = await generate({ instances: [DB] }, m);
57
+ expect(m0?.tag).toBe("0000_migration");
58
+ expect(m0?.steps.map((s) => s.action)).toEqual(["create"]);
59
+
60
+ expect(await generate({ instances: [DB] }, m)).toBeNull(); // config == last snapshot
61
+
62
+ const m1 = await generate({ instances: [DB, KV] }, m, "add-kv");
63
+ expect(m1?.tag).toBe("0001_add-kv");
64
+ expect(m1?.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:kv"]); // ONLY the delta
65
+ expect((await m.lastSnapshot()).instances.map((i) => i.ref)).toEqual(["db", "kv"]);
66
+ });
67
+ });
68
+
69
+ describe("migrate — replay pending migrations in order", () => {
70
+ test("applies pending via the engine, marks them applied, and is idempotent on re-run", async () => {
71
+ const { d1, kv, brokers } = mk();
72
+ const m = memoryMigrationStore();
73
+ const store = memoryStore();
74
+ await generate({ instances: [DB] }, m);
75
+
76
+ const r0 = await migrate({ brokers, store, migrations: m, sink: memorySink() });
77
+ expect(r0.applied).toEqual([0]);
78
+ expect(d1.provisioned).toEqual(["db"]);
79
+ expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]);
80
+
81
+ const again = await migrate({ brokers, store, migrations: m });
82
+ expect(again.upToDate).toBe(true);
83
+ expect(d1.provisioned).toEqual(["db"]); // NOT re-provisioned — already applied
84
+
85
+ // add kv → generate 0001 → migrate provisions only kv (the binding chain resolves @db.database_id from the journal).
86
+ await generate({ instances: [DB, KV] }, m, "add-kv");
87
+ const r1 = await migrate({ brokers, store, migrations: m, sink: memorySink() });
88
+ expect(r1.applied).toEqual([1]);
89
+ expect(kv.provisioned).toEqual(["kv"]);
90
+
91
+ // drop kv → generate 0002 → migrate deprovisions it (the snapshot prunes).
92
+ await generate({ instances: [DB] }, m, "drop-kv");
93
+ await migrate({ brokers, store, migrations: m });
94
+ expect(kv.deprovisioned).toEqual(["kv"]);
95
+ expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]);
96
+ });
97
+ });
98
+
99
+ describe("fileMigrationStore — committed + repeatable across processes", () => {
100
+ test("writes the migration/snapshot/journal; a fresh store over the same dir sees them", async () => {
101
+ const dir = mkdtempSync(join(tmpdir(), "prov-mig-"));
102
+ const store = fileMigrationStore(dir);
103
+ await generate({ instances: [DB] }, store);
104
+ await generate({ instances: [DB, KV] }, store, "add-kv");
105
+ await store.markApplied(0);
106
+
107
+ const fresh = fileMigrationStore(dir); // a new process, same committed dir
108
+ const list = await fresh.listMigrations();
109
+ expect(list.map((mm) => mm.tag)).toEqual(["0000_migration", "0001_add-kv"]);
110
+ expect((await fresh.lastSnapshot()).instances.map((i) => i.ref)).toEqual(["db", "kv"]);
111
+ expect(await fresh.applied()).toEqual([0]); // env-local applied-ledger persisted
112
+ });
113
+ });
114
+
115
+ describe("the CLI — generate + migrate", () => {
116
+ test("generate → migrate → migrate (up to date); no store → exit 2", async () => {
117
+ const { brokers } = mk();
118
+ const migrations = memoryMigrationStore();
119
+ const app = defineProvisionApp({ config: { instances: [DB] }, brokers, store: memoryStore(), sink: memorySink(), migrations });
120
+
121
+ const g = await runCli(app, ["generate", "--name", "init"]);
122
+ expect(g.output).toContain("0000_init");
123
+
124
+ expect((await runCli(app, ["migrate"])).output).toContain("applied 1 migration");
125
+ expect((await runCli(app, ["migrate"])).output).toContain("up to date");
126
+
127
+ const noMig = defineProvisionApp({ config: { instances: [DB] }, brokers, store: memoryStore() });
128
+ expect((await runCli(noMig, ["generate"])).exitCode).toBe(2);
129
+ });
130
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }