@wtfalch/foundry 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/LICENSE +21 -0
- package/dist/apply.d.ts +27 -0
- package/dist/apply.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -0
- package/dist/migrations/0001_declared_org.sql +61 -0
- package/dist/migrations/0002_applied_state.sql +43 -0
- package/dist/migrations/0003_foundry_runs.sql +53 -0
- package/dist/migrations/0004_slug_unique.sql +26 -0
- package/dist/migrations/0005_spend_limits.sql +34 -0
- package/dist/migrations/0006_buyer_identity.sql +31 -0
- package/dist/migrations/0007_site.sql +18 -0
- package/dist/org.d.ts +276 -0
- package/dist/org.js +29 -0
- package/dist/plan/diff.d.ts +52 -0
- package/dist/plan/diff.js +302 -0
- package/dist/plan/index.d.ts +8 -0
- package/dist/plan/index.js +4 -0
- package/dist/plan/live.d.ts +62 -0
- package/dist/plan/live.js +12 -0
- package/dist/plan/plan-apply.d.ts +107 -0
- package/dist/plan/plan-apply.js +172 -0
- package/dist/plan/providers-readers.d.ts +86 -0
- package/dist/plan/providers-readers.js +83 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +1 -0
- package/dist/runner/db.d.ts +8 -0
- package/dist/runner/db.js +1 -0
- package/dist/runner/errors.d.ts +37 -0
- package/dist/runner/errors.js +57 -0
- package/dist/runner/index.d.ts +4 -0
- package/dist/runner/index.js +2 -0
- package/dist/runner/runner.d.ts +6 -0
- package/dist/runner/runner.js +137 -0
- package/dist/runner/schema.d.ts +163 -0
- package/dist/runner/schema.js +25 -0
- package/dist/runner/types.d.ts +97 -0
- package/dist/runner/types.js +1 -0
- package/dist/slug.d.ts +38 -0
- package/dist/slug.js +141 -0
- package/dist/state.d.ts +63 -0
- package/dist/state.js +1 -0
- package/dist/store/postgres.d.ts +43 -0
- package/dist/store/postgres.js +267 -0
- package/dist/store/schema.d.ts +615 -0
- package/dist/store/schema.js +59 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { computePlanDetail, toPlan } from './diff.js';
|
|
3
|
+
import { readLiveResources } from './live.js';
|
|
4
|
+
export class DeclaredOrgNotFoundError extends Error {
|
|
5
|
+
constructor(orgId) {
|
|
6
|
+
super(`no declared org ${orgId}`);
|
|
7
|
+
this.name = 'DeclaredOrgNotFoundError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** #10 (resolved): "an apply is refused when its plan no longer matches reality." */
|
|
11
|
+
export class StalePlanError extends Error {
|
|
12
|
+
constructor(orgId) {
|
|
13
|
+
super(`the plan for org ${orgId} no longer matches live state; recompute plan() and try again (#9, #10)`);
|
|
14
|
+
this.name = 'StalePlanError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** #9 (resolved): "any step that destroys or migrates requires the org's name typed." */
|
|
18
|
+
export class ConfirmationRequiredError extends Error {
|
|
19
|
+
constructor(orgId) {
|
|
20
|
+
super(`the plan for org ${orgId} has a step that destroys or migrates data; the org's name must be typed exactly to apply it (#9)`);
|
|
21
|
+
this.name = 'ConfirmationRequiredError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class NotAuthorizedError extends Error {
|
|
25
|
+
constructor(actor, orgId) {
|
|
26
|
+
super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
|
|
27
|
+
this.name = 'NotAuthorizedError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
32
|
+
* state, and refuses to apply one that has gone stale, one that touches a
|
|
33
|
+
* destructive or migrating step without the org's name typed, or one the
|
|
34
|
+
* actor is not authorized to run.
|
|
35
|
+
*/
|
|
36
|
+
export class FoundryPlanApply {
|
|
37
|
+
store;
|
|
38
|
+
readers;
|
|
39
|
+
executor;
|
|
40
|
+
audit;
|
|
41
|
+
authz;
|
|
42
|
+
actor;
|
|
43
|
+
now;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.store = options.store;
|
|
46
|
+
this.readers = options.readers;
|
|
47
|
+
this.executor = options.executor;
|
|
48
|
+
this.audit = options.audit;
|
|
49
|
+
this.authz = options.authz;
|
|
50
|
+
this.actor = options.actor;
|
|
51
|
+
this.now = options.now ?? (() => new Date());
|
|
52
|
+
}
|
|
53
|
+
async plan(id) {
|
|
54
|
+
const { plan } = await this.computeFreshPlan(id);
|
|
55
|
+
await this.audit.recordPlan({ actor: this.actor, orgId: id, plan, at: this.now() });
|
|
56
|
+
return plan;
|
|
57
|
+
}
|
|
58
|
+
async apply(plan, confirmedOrgName) {
|
|
59
|
+
const orgId = plan.orgId;
|
|
60
|
+
const authorized = await this.authz.canApply(this.actor, orgId);
|
|
61
|
+
if (!authorized) {
|
|
62
|
+
await this.refuse(plan, 'not authorized');
|
|
63
|
+
throw new NotAuthorizedError(this.actor, orgId);
|
|
64
|
+
}
|
|
65
|
+
const { org, plan: freshPlan, detail } = await this.computeFreshPlan(orgId);
|
|
66
|
+
if (!plansMatch(plan, freshPlan)) {
|
|
67
|
+
await this.refuse(plan, 'stale: live state has moved since this plan was computed');
|
|
68
|
+
throw new StalePlanError(orgId);
|
|
69
|
+
}
|
|
70
|
+
const needsConfirmation = plan.steps.some((step) => step.destroys || step.migrates);
|
|
71
|
+
if (needsConfirmation && confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
72
|
+
await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
|
|
73
|
+
throw new ConfirmationRequiredError(orgId);
|
|
74
|
+
}
|
|
75
|
+
return this.runSteps(org, plan, detail);
|
|
76
|
+
}
|
|
77
|
+
async refuse(plan, reason) {
|
|
78
|
+
await this.audit.recordApply({
|
|
79
|
+
actor: this.actor,
|
|
80
|
+
orgId: plan.orgId,
|
|
81
|
+
plan,
|
|
82
|
+
outcome: 'refused',
|
|
83
|
+
reason,
|
|
84
|
+
at: this.now(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async runSteps(org, plan, detail) {
|
|
88
|
+
const orgId = plan.orgId;
|
|
89
|
+
const previous = await this.store.getAppliedState(orgId);
|
|
90
|
+
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
91
|
+
const checkpoints = [];
|
|
92
|
+
try {
|
|
93
|
+
for (const planned of detail.plannedSteps) {
|
|
94
|
+
const result = await this.executor.execute(planned.action);
|
|
95
|
+
if (result?.resourceId)
|
|
96
|
+
serviceIds[planned.action.kind] = result.resourceId;
|
|
97
|
+
checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// #9 (resolved): "it stops at the checkpoint... there is no automatic
|
|
102
|
+
// rollback." Whatever completed stays recorded; the failure is
|
|
103
|
+
// rethrown for a human to fix and resume.
|
|
104
|
+
await this.store.putAppliedState({
|
|
105
|
+
orgId,
|
|
106
|
+
serviceIds,
|
|
107
|
+
checkpoints,
|
|
108
|
+
failedFields: previous?.failedFields ?? [],
|
|
109
|
+
lastApplied: previous?.lastApplied ?? null,
|
|
110
|
+
});
|
|
111
|
+
await this.audit.recordApply({
|
|
112
|
+
actor: this.actor,
|
|
113
|
+
orgId,
|
|
114
|
+
plan,
|
|
115
|
+
outcome: 'failed',
|
|
116
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
117
|
+
at: this.now(),
|
|
118
|
+
});
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
const applied = {
|
|
122
|
+
orgId,
|
|
123
|
+
serviceIds,
|
|
124
|
+
checkpoints,
|
|
125
|
+
// Carried forward, never recomputed. #18 added `failedFields` and left
|
|
126
|
+
// "whether apply throws, partially applies, or does both" to #19, but
|
|
127
|
+
// this implementation was written before the field existed and has no
|
|
128
|
+
// opinion on it: `computePlanDetail` plans only the issuer, mail and
|
|
129
|
+
// chat, so it cannot yet say which declared fields it failed to make
|
|
130
|
+
// true. Writing `[]` here would claim it had checked. #20 wires the
|
|
131
|
+
// full provisioning surface and is what computes this list.
|
|
132
|
+
failedFields: previous?.failedFields ?? [],
|
|
133
|
+
lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
|
|
134
|
+
};
|
|
135
|
+
await this.store.putAppliedState(applied);
|
|
136
|
+
await this.audit.recordApply({
|
|
137
|
+
actor: this.actor,
|
|
138
|
+
orgId,
|
|
139
|
+
plan,
|
|
140
|
+
outcome: 'applied',
|
|
141
|
+
at: this.now(),
|
|
142
|
+
});
|
|
143
|
+
return applied;
|
|
144
|
+
}
|
|
145
|
+
async computeFreshPlan(id) {
|
|
146
|
+
const org = await this.store.get(id);
|
|
147
|
+
if (!org)
|
|
148
|
+
throw new DeclaredOrgNotFoundError(id);
|
|
149
|
+
const live = await readLiveResources(id, this.readers);
|
|
150
|
+
const detail = computePlanDetail(org, live);
|
|
151
|
+
return { org, plan: toPlan(detail), detail };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Order-independent structural equality between two plans. */
|
|
155
|
+
function plansMatch(a, b) {
|
|
156
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
157
|
+
}
|
|
158
|
+
function canonical(plan) {
|
|
159
|
+
return {
|
|
160
|
+
orgId: plan.orgId,
|
|
161
|
+
steps: [...plan.steps].sort((x, y) => x.description.localeCompare(y.description)),
|
|
162
|
+
unmanaged: [...plan.unmanaged].sort((x, y) => x.description.localeCompare(y.description)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* A structural version of the declared org, recorded on {@link AppliedState}
|
|
167
|
+
* so a later reader can tell whether it changed since this apply —
|
|
168
|
+
* `DeclaredOrg` (org.ts) carries no version field of its own.
|
|
169
|
+
*/
|
|
170
|
+
function hashOrg(org) {
|
|
171
|
+
return createHash('sha256').update(JSON.stringify(org)).digest('hex');
|
|
172
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
|
+
import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
|
+
import type { ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
|
+
import type { OrgId } from '../org.js';
|
|
5
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
6
|
+
import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
7
|
+
/**
|
|
8
|
+
* Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
|
|
9
|
+
* (`live.ts:31-45`), backed by `@wtfalch/providers`' HTTP clients instead of
|
|
10
|
+
* the fakes `plan-apply.test.ts` and `diff.test.ts` use (`../test/fakes.ts`'s
|
|
11
|
+
* `FakeReader`).
|
|
12
|
+
*
|
|
13
|
+
* This is the smallest real use #20 ("provision Company Factory end to end")
|
|
14
|
+
* names, per `providers`' own integration scout
|
|
15
|
+
* (`providers/.claude/scout/2026-09-23-integration.md`, "foundry" row): "New
|
|
16
|
+
* adapter code implementing `CoolifyReader`/`CloudflareReader`/`ZitadelReader`
|
|
17
|
+
* ... over `@wtfalch/providers/{coolify,cloudflare,zitadel}`." That scout also
|
|
18
|
+
* names a `StepExecutor` half; this file deliberately covers reads only — see
|
|
19
|
+
* the Finding on #20 in this repo's phase-B report for why the write half
|
|
20
|
+
* (creating/mutating live Coolify/Cloudflare/Zitadel resources) is left for
|
|
21
|
+
* #20 itself rather than folded into this adoption PR.
|
|
22
|
+
*
|
|
23
|
+
* `packages/foundry/package.json` had zero `@wtfalch/*` dependencies before
|
|
24
|
+
* this file; `@wtfalch/providers` is its first.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Resolves an estate {@link OrgId} to one provider's own scope id — a
|
|
28
|
+
* Coolify project uuid, a Cloudflare zone id, ZITADEL's own org id — or
|
|
29
|
+
* `null` before that provider has anything provisioned for this org yet.
|
|
30
|
+
*
|
|
31
|
+
* Every `@wtfalch/providers` client's `listResources` takes that provider's
|
|
32
|
+
* own scope, never foundry's `OrgId` directly (`providers/packages/providers/
|
|
33
|
+
* src/resource.ts`'s doc comment, ADR 0002: providers imports nothing from
|
|
34
|
+
* the estate, so it cannot resolve foundry's id itself — "resolving an
|
|
35
|
+
* estate org to that scope is foundry's job"). This type makes that lookup
|
|
36
|
+
* explicit and injectable per reader, rather than hard-coding one id map's
|
|
37
|
+
* key name into each reader class.
|
|
38
|
+
*/
|
|
39
|
+
export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
|
|
40
|
+
/**
|
|
41
|
+
* A {@link ScopeResolver} backed by `AppliedState.serviceIds`
|
|
42
|
+
* (`../state.ts`: "one id per service ... keyed by that service's own name
|
|
43
|
+
* for the org") — the one place foundry records a provider's own id for an
|
|
44
|
+
* org today, through the same `DeclaredOrgStore` seam #18 built.
|
|
45
|
+
*
|
|
46
|
+
* Nothing writes a `serviceIds[serviceKey]` entry yet — that is the
|
|
47
|
+
* `StepExecutor` half of #20, not built here — so every resolver built this
|
|
48
|
+
* way returns `null` for every org until it does. That is the correct
|
|
49
|
+
* "nothing live yet" answer: a reader passed `null` returns `[]`, which
|
|
50
|
+
* `computePlanDetail` (`diff.ts`) already reads as "no live resource",
|
|
51
|
+
* exactly right for an org with no Coolify project (etc.) yet.
|
|
52
|
+
*/
|
|
53
|
+
export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
|
|
54
|
+
/**
|
|
55
|
+
* Backed by `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`: a
|
|
56
|
+
* Coolify project's environments and in-scope applications (v1 scope — see
|
|
57
|
+
* that method's own doc comment for what it leaves out).
|
|
58
|
+
*/
|
|
59
|
+
export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
60
|
+
private readonly client;
|
|
61
|
+
private readonly resolveScope;
|
|
62
|
+
constructor(client: CoolifyClient, resolveScope: ScopeResolver);
|
|
63
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Backed by `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`:
|
|
67
|
+
* a zone's DNS records plus its email-routing status (v1 scope — see that
|
|
68
|
+
* method's own doc comment for what it leaves out).
|
|
69
|
+
*/
|
|
70
|
+
export declare class ProvidersCloudflareReader implements CloudflareReader {
|
|
71
|
+
private readonly client;
|
|
72
|
+
private readonly resolveScope;
|
|
73
|
+
constructor(client: CloudflareClient, resolveScope: ScopeResolver);
|
|
74
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
|
|
78
|
+
* resource per project and per project grant in a ZITADEL org (v1 scope —
|
|
79
|
+
* see that method's own doc comment).
|
|
80
|
+
*/
|
|
81
|
+
export declare class ProvidersZitadelReader implements ZitadelReader {
|
|
82
|
+
private readonly client;
|
|
83
|
+
private readonly resolveScope;
|
|
84
|
+
constructor(client: ZitadelClient, resolveScope: ScopeResolver);
|
|
85
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
86
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link ScopeResolver} backed by `AppliedState.serviceIds`
|
|
3
|
+
* (`../state.ts`: "one id per service ... keyed by that service's own name
|
|
4
|
+
* for the org") — the one place foundry records a provider's own id for an
|
|
5
|
+
* org today, through the same `DeclaredOrgStore` seam #18 built.
|
|
6
|
+
*
|
|
7
|
+
* Nothing writes a `serviceIds[serviceKey]` entry yet — that is the
|
|
8
|
+
* `StepExecutor` half of #20, not built here — so every resolver built this
|
|
9
|
+
* way returns `null` for every org until it does. That is the correct
|
|
10
|
+
* "nothing live yet" answer: a reader passed `null` returns `[]`, which
|
|
11
|
+
* `computePlanDetail` (`diff.ts`) already reads as "no live resource",
|
|
12
|
+
* exactly right for an org with no Coolify project (etc.) yet.
|
|
13
|
+
*/
|
|
14
|
+
export function storeScopeResolver(store, serviceKey) {
|
|
15
|
+
return async (orgId) => {
|
|
16
|
+
const applied = await store.getAppliedState(orgId);
|
|
17
|
+
// `|| null`, not `??`: an empty string is not a usable scope id either,
|
|
18
|
+
// and no provider mints one, so there is nothing to lose by treating it
|
|
19
|
+
// the same as "not set yet".
|
|
20
|
+
return applied?.serviceIds[serviceKey] || null;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
24
|
+
function toFoundryResources(system, resources) {
|
|
25
|
+
return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Backed by `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`: a
|
|
29
|
+
* Coolify project's environments and in-scope applications (v1 scope — see
|
|
30
|
+
* that method's own doc comment for what it leaves out).
|
|
31
|
+
*/
|
|
32
|
+
export class ProvidersCoolifyReader {
|
|
33
|
+
client;
|
|
34
|
+
resolveScope;
|
|
35
|
+
constructor(client, resolveScope) {
|
|
36
|
+
this.client = client;
|
|
37
|
+
this.resolveScope = resolveScope;
|
|
38
|
+
}
|
|
39
|
+
async listResources(orgId) {
|
|
40
|
+
const projectUuid = await this.resolveScope(orgId);
|
|
41
|
+
if (!projectUuid)
|
|
42
|
+
return [];
|
|
43
|
+
return toFoundryResources('coolify', await this.client.listResources(projectUuid));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Backed by `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`:
|
|
48
|
+
* a zone's DNS records plus its email-routing status (v1 scope — see that
|
|
49
|
+
* method's own doc comment for what it leaves out).
|
|
50
|
+
*/
|
|
51
|
+
export class ProvidersCloudflareReader {
|
|
52
|
+
client;
|
|
53
|
+
resolveScope;
|
|
54
|
+
constructor(client, resolveScope) {
|
|
55
|
+
this.client = client;
|
|
56
|
+
this.resolveScope = resolveScope;
|
|
57
|
+
}
|
|
58
|
+
async listResources(orgId) {
|
|
59
|
+
const zoneId = await this.resolveScope(orgId);
|
|
60
|
+
if (!zoneId)
|
|
61
|
+
return [];
|
|
62
|
+
return toFoundryResources('cloudflare', await this.client.listResources(zoneId));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
|
|
67
|
+
* resource per project and per project grant in a ZITADEL org (v1 scope —
|
|
68
|
+
* see that method's own doc comment).
|
|
69
|
+
*/
|
|
70
|
+
export class ProvidersZitadelReader {
|
|
71
|
+
client;
|
|
72
|
+
resolveScope;
|
|
73
|
+
constructor(client, resolveScope) {
|
|
74
|
+
this.client = client;
|
|
75
|
+
this.resolveScope = resolveScope;
|
|
76
|
+
}
|
|
77
|
+
async listResources(orgId) {
|
|
78
|
+
const zitadelOrgId = await this.resolveScope(orgId);
|
|
79
|
+
if (!zitadelOrgId)
|
|
80
|
+
return [];
|
|
81
|
+
return toFoundryResources('zitadel', await this.client.listResources(zitadelOrgId));
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { OrgId } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* One step of a {@link Plan}.
|
|
4
|
+
*
|
|
5
|
+
* Decided in #9 (resolved): "any step that destroys or migrates requires
|
|
6
|
+
* the org's name typed" before `apply` runs it. `destroys` and `migrates`
|
|
7
|
+
* are independent — a migration can move live data without deleting
|
|
8
|
+
* anything, but both are gated the same way at apply time.
|
|
9
|
+
*/
|
|
10
|
+
export interface PlanStep {
|
|
11
|
+
description: string;
|
|
12
|
+
/**
|
|
13
|
+
* True when this step deletes or replaces data the org cannot get back
|
|
14
|
+
* without foundry's own backup: a chat re-key, an issuer move, a mail
|
|
15
|
+
* domain removal (parked, not deleted, per #7 corrections 1, but still
|
|
16
|
+
* disruptive enough to gate). Requires the org's name typed (#9).
|
|
17
|
+
*/
|
|
18
|
+
destroys: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* True when this step moves live data between systems rather than only
|
|
21
|
+
* creating or updating config — a chat re-key copying rooms and messages,
|
|
22
|
+
* a mail level transition (#7 "Transitions"). Every transition is legal
|
|
23
|
+
* in both directions; which ones foundry builds first is scope for #13,
|
|
24
|
+
* not a property of this type.
|
|
25
|
+
*/
|
|
26
|
+
migrates: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A live-scope item the declared org does not name.
|
|
30
|
+
*
|
|
31
|
+
* Decided in #10 (resolved): "foundry manages only what the declared org
|
|
32
|
+
* names; anything else in scope is reported as unmanaged and never
|
|
33
|
+
* deleted." A {@link Plan} lists these for visibility; it never turns one
|
|
34
|
+
* into a {@link PlanStep}.
|
|
35
|
+
*/
|
|
36
|
+
export interface UnmanagedItem {
|
|
37
|
+
description: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The diff between a {@link DeclaredOrg} and live reality, computed by the
|
|
41
|
+
* `plan` half of {@link PlanApply} and shown before any apply runs.
|
|
42
|
+
*
|
|
43
|
+
* Decided in #9 (resolved): "foundry computes the diff between the declared
|
|
44
|
+
* org and reality, shows it, and an apply runs exactly that plan."
|
|
45
|
+
*/
|
|
46
|
+
export interface Plan {
|
|
47
|
+
orgId: OrgId;
|
|
48
|
+
steps: PlanStep[];
|
|
49
|
+
unmanaged: UnmanagedItem[];
|
|
50
|
+
}
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
/**
|
|
3
|
+
* The host's own drizzle handle, or a transaction open on it: postgres-js
|
|
4
|
+
* in a host, PGlite in this package's own tests. The runner's queries use
|
|
5
|
+
* nothing driver-specific, the same shape `@wtfalch/keys`' `HeldKeysDb`
|
|
6
|
+
* uses.
|
|
7
|
+
*/
|
|
8
|
+
export type RunnerDb = PgDatabase<PgQueryResultHKT, any, any>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RunRecord } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link JobRunner.enqueueApply} when the named org already has a
|
|
4
|
+
* run that has not succeeded -- the database's lock (#9, resolved: a row,
|
|
5
|
+
* never a lockfile) refusing a second apply and saying why.
|
|
6
|
+
*/
|
|
7
|
+
export declare class AlreadyRunningError extends Error {
|
|
8
|
+
readonly orgId: string;
|
|
9
|
+
readonly existingRun: RunRecord;
|
|
10
|
+
constructor(orgId: string, existingRun: RunRecord);
|
|
11
|
+
}
|
|
12
|
+
/** Thrown by {@link JobRunner.runSteps} when the named run does not exist. */
|
|
13
|
+
export declare class RunNotFoundError extends Error {
|
|
14
|
+
readonly runId: string;
|
|
15
|
+
constructor(runId: string);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Thrown by {@link JobRunner.runSteps} when the caller's step list does not
|
|
19
|
+
* match the run's own `stepIds` -- guards a resume against being handed a
|
|
20
|
+
* different plan than the one that was locked in at enqueue time.
|
|
21
|
+
*/
|
|
22
|
+
export declare class StepMismatchError extends Error {
|
|
23
|
+
readonly runId: string;
|
|
24
|
+
constructor(runId: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Thrown by {@link JobRunner.runSteps} when a step's own `run()` threw. The
|
|
28
|
+
* run's row already records the failure (#17: "records which step
|
|
29
|
+
* failed") before this is thrown -- this is only the caller-facing signal
|
|
30
|
+
* that the run stopped.
|
|
31
|
+
*/
|
|
32
|
+
export declare class StepFailedError extends Error {
|
|
33
|
+
readonly runId: string;
|
|
34
|
+
readonly step: string;
|
|
35
|
+
constructor(runId: string, step: string, cause: unknown);
|
|
36
|
+
}
|
|
37
|
+
export declare function errorMessage(error: unknown): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by {@link JobRunner.enqueueApply} when the named org already has a
|
|
3
|
+
* run that has not succeeded -- the database's lock (#9, resolved: a row,
|
|
4
|
+
* never a lockfile) refusing a second apply and saying why.
|
|
5
|
+
*/
|
|
6
|
+
export class AlreadyRunningError extends Error {
|
|
7
|
+
orgId;
|
|
8
|
+
existingRun;
|
|
9
|
+
constructor(orgId, existingRun) {
|
|
10
|
+
super(`org ${orgId} already has an apply run in progress (run ${existingRun.id}, ` +
|
|
11
|
+
`${existingRun.status}); refusing a second apply until it succeeds`);
|
|
12
|
+
this.name = 'AlreadyRunningError';
|
|
13
|
+
this.orgId = orgId;
|
|
14
|
+
this.existingRun = existingRun;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Thrown by {@link JobRunner.runSteps} when the named run does not exist. */
|
|
18
|
+
export class RunNotFoundError extends Error {
|
|
19
|
+
runId;
|
|
20
|
+
constructor(runId) {
|
|
21
|
+
super(`no such run: ${runId}`);
|
|
22
|
+
this.name = 'RunNotFoundError';
|
|
23
|
+
this.runId = runId;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Thrown by {@link JobRunner.runSteps} when the caller's step list does not
|
|
28
|
+
* match the run's own `stepIds` -- guards a resume against being handed a
|
|
29
|
+
* different plan than the one that was locked in at enqueue time.
|
|
30
|
+
*/
|
|
31
|
+
export class StepMismatchError extends Error {
|
|
32
|
+
runId;
|
|
33
|
+
constructor(runId) {
|
|
34
|
+
super(`run ${runId}: supplied steps do not match the run's planned steps`);
|
|
35
|
+
this.name = 'StepMismatchError';
|
|
36
|
+
this.runId = runId;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Thrown by {@link JobRunner.runSteps} when a step's own `run()` threw. The
|
|
41
|
+
* run's row already records the failure (#17: "records which step
|
|
42
|
+
* failed") before this is thrown -- this is only the caller-facing signal
|
|
43
|
+
* that the run stopped.
|
|
44
|
+
*/
|
|
45
|
+
export class StepFailedError extends Error {
|
|
46
|
+
runId;
|
|
47
|
+
step;
|
|
48
|
+
constructor(runId, step, cause) {
|
|
49
|
+
super(`run ${runId}: step ${step} failed: ${errorMessage(cause)}`, { cause });
|
|
50
|
+
this.name = 'StepFailedError';
|
|
51
|
+
this.runId = runId;
|
|
52
|
+
this.step = step;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function errorMessage(error) {
|
|
56
|
+
return error instanceof Error ? error.message : String(error);
|
|
57
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { RunnerDb } from './db.js';
|
|
2
|
+
export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './errors.js';
|
|
3
|
+
export { createJobRunner } from './runner.js';
|
|
4
|
+
export type { CreateJobRunnerOptions, JobRunner, RunFailure, RunRecord, RunStatus, RunStepsOptions, RunnerStep, } from './types.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CreateJobRunnerOptions, JobRunner } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creates the job runner (#17). `db` is the host's own drizzle handle,
|
|
4
|
+
* already migrated with `migrations/0001_foundry_runs.sql`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createJobRunner(options: CreateJobRunnerOptions): JobRunner;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, eq, ne } from 'drizzle-orm';
|
|
3
|
+
import { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, errorMessage, } from './errors.js';
|
|
4
|
+
import { foundryRuns } from './schema.js';
|
|
5
|
+
const UNIQUE_VIOLATION = '23505';
|
|
6
|
+
/**
|
|
7
|
+
* True for a Postgres unique-violation, however the driver hands it back --
|
|
8
|
+
* directly (PGlite in this package's own tests) or wrapped in drizzle-orm's
|
|
9
|
+
* own `DrizzleQueryError`, whose `cause` carries the raw driver error
|
|
10
|
+
* (postgres-js in a host). Checked by Postgres error code, never by message
|
|
11
|
+
* text, since message text is not a contract.
|
|
12
|
+
*/
|
|
13
|
+
function isUniqueViolation(error) {
|
|
14
|
+
const code = error?.code;
|
|
15
|
+
if (code === UNIQUE_VIOLATION)
|
|
16
|
+
return true;
|
|
17
|
+
const cause = error?.cause;
|
|
18
|
+
return cause?.code === UNIQUE_VIOLATION;
|
|
19
|
+
}
|
|
20
|
+
function toRunRecord(row) {
|
|
21
|
+
return {
|
|
22
|
+
id: row.id,
|
|
23
|
+
orgId: row.orgId,
|
|
24
|
+
status: row.status,
|
|
25
|
+
stepIds: row.stepIds,
|
|
26
|
+
checkpoints: row.checkpoints,
|
|
27
|
+
failure: row.failure ?? null,
|
|
28
|
+
createdAt: row.createdAt.toISOString(),
|
|
29
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Same order, same ids -- the guard {@link JobRunner.runSteps} applies before running anything. */
|
|
33
|
+
function stepsMatch(stepIds, steps) {
|
|
34
|
+
return stepIds.length === steps.length && stepIds.every((id, index) => steps[index]?.id === id);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates the job runner (#17). `db` is the host's own drizzle handle,
|
|
38
|
+
* already migrated with `migrations/0001_foundry_runs.sql`.
|
|
39
|
+
*/
|
|
40
|
+
export function createJobRunner(options) {
|
|
41
|
+
const { db } = options;
|
|
42
|
+
const now = options.now ?? (() => new Date());
|
|
43
|
+
async function getRun(runId) {
|
|
44
|
+
const [row] = await db.select().from(foundryRuns).where(eq(foundryRuns.id, runId));
|
|
45
|
+
return row ? toRunRecord(row) : null;
|
|
46
|
+
}
|
|
47
|
+
async function currentRun(orgId) {
|
|
48
|
+
const [row] = await db
|
|
49
|
+
.select()
|
|
50
|
+
.from(foundryRuns)
|
|
51
|
+
.where(and(eq(foundryRuns.orgId, orgId), ne(foundryRuns.status, 'succeeded')));
|
|
52
|
+
return row ? toRunRecord(row) : null;
|
|
53
|
+
}
|
|
54
|
+
async function enqueueApply(orgId, steps) {
|
|
55
|
+
const stepIds = steps.map((step) => step.id);
|
|
56
|
+
if (new Set(stepIds).size !== stepIds.length)
|
|
57
|
+
throw new Error('runner steps must have unique ids');
|
|
58
|
+
const id = randomUUID();
|
|
59
|
+
try {
|
|
60
|
+
await db
|
|
61
|
+
.insert(foundryRuns)
|
|
62
|
+
.values({ id, orgId, status: 'running', stepIds, checkpoints: [], failure: null });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (isUniqueViolation(error)) {
|
|
66
|
+
const existing = await currentRun(orgId);
|
|
67
|
+
if (existing)
|
|
68
|
+
throw new AlreadyRunningError(orgId, existing);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return { runId: id };
|
|
73
|
+
}
|
|
74
|
+
async function runSteps(runId, steps, stepOptions = {}) {
|
|
75
|
+
const run = await getRun(runId);
|
|
76
|
+
if (!run)
|
|
77
|
+
throw new RunNotFoundError(runId);
|
|
78
|
+
if (run.status === 'succeeded')
|
|
79
|
+
return run;
|
|
80
|
+
if (!stepsMatch(run.stepIds, steps))
|
|
81
|
+
throw new StepMismatchError(runId);
|
|
82
|
+
if (run.status === 'failed')
|
|
83
|
+
// Resuming a failed run (#9: "a human fixes the cause and resumes
|
|
84
|
+
// from that checkpoint") clears the stale failure record -- it
|
|
85
|
+
// described the last attempt, not this one.
|
|
86
|
+
await db
|
|
87
|
+
.update(foundryRuns)
|
|
88
|
+
.set({ status: 'running', failure: null, updatedAt: now() })
|
|
89
|
+
.where(eq(foundryRuns.id, runId));
|
|
90
|
+
const stepById = new Map(steps.map((step) => [step.id, step]));
|
|
91
|
+
const checkpoints = [...run.checkpoints];
|
|
92
|
+
const done = new Set(checkpoints.map((c) => c.step));
|
|
93
|
+
for (const stepId of run.stepIds) {
|
|
94
|
+
if (done.has(stepId))
|
|
95
|
+
continue;
|
|
96
|
+
if (stepOptions.signal?.aborted)
|
|
97
|
+
break;
|
|
98
|
+
// stepsMatch already proved every run.stepIds entry has a matching step.
|
|
99
|
+
const step = stepById.get(stepId);
|
|
100
|
+
try {
|
|
101
|
+
await step.run();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const failure = { step: stepId, message: errorMessage(error), at: now().toISOString() };
|
|
105
|
+
await db
|
|
106
|
+
.update(foundryRuns)
|
|
107
|
+
.set({ status: 'failed', failure, updatedAt: now() })
|
|
108
|
+
.where(eq(foundryRuns.id, runId));
|
|
109
|
+
throw new StepFailedError(runId, stepId, error);
|
|
110
|
+
}
|
|
111
|
+
checkpoints.push({ step: stepId, completedAt: now().toISOString() });
|
|
112
|
+
await db
|
|
113
|
+
.update(foundryRuns)
|
|
114
|
+
.set({ checkpoints: [...checkpoints], updatedAt: now() })
|
|
115
|
+
.where(eq(foundryRuns.id, runId));
|
|
116
|
+
}
|
|
117
|
+
if (checkpoints.length < run.stepIds.length) {
|
|
118
|
+
// Stopped early (the signal fired before every step ran) -- leave the
|
|
119
|
+
// row exactly as its last checkpoint left it; this is the state a
|
|
120
|
+
// real kill would also leave, and the next call resumes it the same
|
|
121
|
+
// way regardless of which one happened.
|
|
122
|
+
const current = await getRun(runId);
|
|
123
|
+
if (!current)
|
|
124
|
+
throw new RunNotFoundError(runId);
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
await db
|
|
128
|
+
.update(foundryRuns)
|
|
129
|
+
.set({ status: 'succeeded', updatedAt: now() })
|
|
130
|
+
.where(eq(foundryRuns.id, runId));
|
|
131
|
+
const finished = await getRun(runId);
|
|
132
|
+
if (!finished)
|
|
133
|
+
throw new RunNotFoundError(runId);
|
|
134
|
+
return finished;
|
|
135
|
+
}
|
|
136
|
+
return { enqueueApply, runSteps, getRun, currentRun };
|
|
137
|
+
}
|