@wtfalch/foundry 0.1.0 → 0.2.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.
@@ -1,15 +1,21 @@
1
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.
2
+ * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
3
+ * — the one place foundry records a provider's own id for an org today,
4
+ * through the same `DeclaredOrgStore` seam #18 built.
6
5
  *
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.
6
+ * `runSteps` (`plan-apply.ts`) now writes one `serviceIds` entry per
7
+ * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
8
+ * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
9
+ * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
10
+ * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
11
+ * three `serviceKey`s returns the real id once that step has run. A
12
+ * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
13
+ * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
14
+ * (a resource id, read back by {@link storeRecordedIds}), not under a plain
15
+ * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
16
+ * still returns `null` for every org. That is the correct "nothing live yet"
17
+ * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
18
+ * (`diff.ts`) already reads as "no live resource".
13
19
  */
14
20
  export function storeScopeResolver(store, serviceKey) {
15
21
  return async (orgId) => {
@@ -20,6 +26,56 @@ export function storeScopeResolver(store, serviceKey) {
20
26
  return applied?.serviceIds[serviceKey] || null;
21
27
  };
22
28
  }
29
+ export function storeRecordedIds(store, system) {
30
+ return async (orgId) => {
31
+ const applied = await store.getAppliedState(orgId);
32
+ const recorded = {};
33
+ if (!applied)
34
+ return recorded;
35
+ for (const [resourceKey, providerId] of Object.entries(applied.serviceIds)) {
36
+ const [keySystem, kind, ...rest] = resourceKey.split(':');
37
+ if (keySystem !== system || kind === undefined || rest.length === 0)
38
+ continue;
39
+ recorded[providerId] = `${kind}:${rest.join(':')}`;
40
+ }
41
+ return recorded;
42
+ };
43
+ }
44
+ /**
45
+ * Maps each live resource whose own id is one `recorded` names onto the
46
+ * foundry `kind`/`key` it was recorded under, keeping `config` exactly as
47
+ * the provider gave it (design doc, "Design for (1)": "the reader then maps
48
+ * each provider resource whose `key` equals a recorded provider id onto the
49
+ * recorded foundry `kind` and `key`"). A resource nothing recorded passes
50
+ * through unchanged, so `computePlanDetail` (`diff.ts`) still reports it
51
+ * unmanaged (#10) rather than this boundary guessing at a match.
52
+ *
53
+ * `dropUnrecordedKinds` names kinds that are dropped outright instead of
54
+ * passed through when unrecorded. Cloudflare's `tunnel` is the one case: a
55
+ * tunnel is account-wide, so an unrecorded one belongs to some other org,
56
+ * not this one, and every org's plan would otherwise report the same
57
+ * foreign tunnel as unmanaged.
58
+ */
59
+ function applyRecordedIds(resources, recorded, dropUnrecordedKinds = new Set()) {
60
+ const mapped = [];
61
+ for (const resource of resources) {
62
+ const foundryKey = recorded[resource.key];
63
+ if (!foundryKey) {
64
+ if (!dropUnrecordedKinds.has(resource.kind))
65
+ mapped.push(resource);
66
+ continue;
67
+ }
68
+ const separator = foundryKey.indexOf(':');
69
+ mapped.push({
70
+ ...resource,
71
+ kind: foundryKey.slice(0, separator),
72
+ key: foundryKey.slice(separator + 1),
73
+ });
74
+ }
75
+ return mapped;
76
+ }
77
+ /** Cloudflare tunnels this org has not recorded are dropped — see {@link applyRecordedIds}. */
78
+ const CLOUDFLARE_DROP_UNRECORDED = new Set(['tunnel']);
23
79
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
24
80
  function toFoundryResources(system, resources) {
25
81
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -32,15 +88,21 @@ function toFoundryResources(system, resources) {
32
88
  export class ProvidersCoolifyReader {
33
89
  client;
34
90
  resolveScope;
35
- constructor(client, resolveScope) {
91
+ resolveRecordedIds;
92
+ constructor(client, resolveScope, resolveRecordedIds) {
36
93
  this.client = client;
37
94
  this.resolveScope = resolveScope;
95
+ this.resolveRecordedIds = resolveRecordedIds;
38
96
  }
39
97
  async listResources(orgId) {
40
98
  const projectUuid = await this.resolveScope(orgId);
41
99
  if (!projectUuid)
42
100
  return [];
43
- return toFoundryResources('coolify', await this.client.listResources(projectUuid));
101
+ const [resources, recorded] = await Promise.all([
102
+ this.client.listResources(projectUuid),
103
+ this.resolveRecordedIds(orgId),
104
+ ]);
105
+ return applyRecordedIds(toFoundryResources('coolify', resources), recorded);
44
106
  }
45
107
  }
46
108
  /**
@@ -51,15 +113,24 @@ export class ProvidersCoolifyReader {
51
113
  export class ProvidersCloudflareReader {
52
114
  client;
53
115
  resolveScope;
54
- constructor(client, resolveScope) {
116
+ resolveRecordedIds;
117
+ constructor(client, resolveScope, resolveRecordedIds) {
55
118
  this.client = client;
56
119
  this.resolveScope = resolveScope;
120
+ this.resolveRecordedIds = resolveRecordedIds;
57
121
  }
58
122
  async listResources(orgId) {
59
123
  const zoneId = await this.resolveScope(orgId);
60
124
  if (!zoneId)
61
125
  return [];
62
- return toFoundryResources('cloudflare', await this.client.listResources(zoneId));
126
+ const [resources, recorded] = await Promise.all([
127
+ this.client.listResources(zoneId),
128
+ this.resolveRecordedIds(orgId),
129
+ ]);
130
+ // A zone needs no mapping: its key is already the zone name, which is
131
+ // foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
132
+ // recorded provider id and always passes through unchanged.
133
+ return applyRecordedIds(toFoundryResources('cloudflare', resources), recorded, CLOUDFLARE_DROP_UNRECORDED);
63
134
  }
64
135
  }
65
136
  /**
@@ -70,14 +141,20 @@ export class ProvidersCloudflareReader {
70
141
  export class ProvidersZitadelReader {
71
142
  client;
72
143
  resolveScope;
73
- constructor(client, resolveScope) {
144
+ resolveRecordedIds;
145
+ constructor(client, resolveScope, resolveRecordedIds) {
74
146
  this.client = client;
75
147
  this.resolveScope = resolveScope;
148
+ this.resolveRecordedIds = resolveRecordedIds;
76
149
  }
77
150
  async listResources(orgId) {
78
151
  const zitadelOrgId = await this.resolveScope(orgId);
79
152
  if (!zitadelOrgId)
80
153
  return [];
81
- return toFoundryResources('zitadel', await this.client.listResources(zitadelOrgId));
154
+ const [resources, recorded] = await Promise.all([
155
+ this.client.listResources(zitadelOrgId),
156
+ this.resolveRecordedIds(orgId),
157
+ ]);
158
+ return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
82
159
  }
83
160
  }
package/dist/state.d.ts CHANGED
@@ -34,17 +34,38 @@ export interface LastApplied {
34
34
  export interface AppliedState {
35
35
  orgId: OrgId;
36
36
  /**
37
- * One id per service that takes jobs from many orgs (Files, AI,
38
- * integrations — https://github.com/wtfalch/foundry/issues/14's resolved
39
- * research #4) plus the per-org services (chat, mail), each keyed by that
40
- * service's own name for the org.
37
+ * One id per resource `plan/plan-apply.ts`'s `runSteps` has made true,
38
+ * keyed `system:kind:key` — the same resource key `plan/diff.ts` plans
39
+ * against (e.g. `coolify:app:<orgId>`, `zitadel:app:<name>`), so two
40
+ * resources that share a `kind` (an org's several declared ZITADEL apps,
41
+ * #71) never overwrite each other's id the way a bare `kind` key did.
42
+ * `plan/providers-readers.ts`'s `storeRecordedIds` reads this map back to
43
+ * match a live provider resource to the foundry resource it was recorded
44
+ * for, instead of reporting it unmanaged (design:
45
+ * `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for
46
+ * (1)").
47
+ *
48
+ * A provider *scope* id — `coolify_project`, `zitadel_org`,
49
+ * `zitadel_project`, a Cloudflare zone id (`plan/providers-readers.ts`'s
50
+ * `ScopeResolver`, https://github.com/wtfalch/foundry/issues/14's resolved
51
+ * research #4) — is a different id space and keeps its own plain key; a
52
+ * scope key has no `:` in it, so it can never collide with the
53
+ * `system:kind:key` form. `plan/executor.ts`'s `FoundryStepExecutor` is
54
+ * what writes these today, through `plan-apply.ts`'s
55
+ * `StepExecutionContext.recordScopeId` — backed by the very `serviceIds`
56
+ * map `runSteps` builds here and persists in one write at the end of a
57
+ * run, not a separate store write of its own (`StepExecutionContext`'s doc
58
+ * comment explains why: a second writer would race `runSteps`' own final
59
+ * one).
41
60
  */
42
61
  serviceIds: Record<string, string>;
43
62
  /** Checkpoints from the current or most recent apply. */
44
63
  checkpoints: Checkpoint[];
45
64
  /**
46
65
  * The name of every {@link DeclaredOrg} field the most recent apply could
47
- * not make true, because nothing implements it yet.
66
+ * not make true: because nothing implements it yet, or because its step
67
+ * had not completed when the apply failed. `apply` recomputes it every
68
+ * time (#25), in `plan/plan-apply.ts`'s `failedFields`.
48
69
  *
49
70
  * Decision (#18): this field did not exist on the type this ticket found.
50
71
  * #7 ("Shape") and #18's "Done when" both require `apply` to "fail loudly
@@ -34,7 +34,9 @@ export declare class DeclaredOrgError extends Error {
34
34
  * on both `create` and `update`. #27 adds a third, narrower rule: storage and
35
35
  * AI spend limits are bigint-safe decimal strings of non-negative integers
36
36
  * (or `null` for uncapped), checked the same way on both `create` and the
37
- * merged result of `update`. The broader "combinations foundry knows are
37
+ * merged result of `update`. #67 adds a fourth (`org.ts`'s
38
+ * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
39
+ * checked the same way. The broader "combinations foundry knows are
38
40
  * impossible are rejected with a reason" line needs a ruleset (domain
39
41
  * requirements per service, `adoptedApp.form`, mail-level transitions…)
40
42
  * that later tickets, not #18, #27 or #28, decide; `update` still merges and
@@ -1,5 +1,5 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
- import { validateBuyerIdentity } from '../org.js';
2
+ import { validateBuyerIdentity, validateHosting, validateOwner } from '../org.js';
3
3
  import { validateSlug } from '../slug.js';
4
4
  import { declaredOrgs, foundryAppliedState, } from './schema.js';
5
5
  const UNIQUE_VIOLATION = '23505';
@@ -50,6 +50,15 @@ function rowToOrg(row) {
50
50
  calls: { usesDomain: row.callsUsesDomain },
51
51
  issuer: { usesDomain: row.issuerUsesDomain },
52
52
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
53
+ hosting: {
54
+ enabled: row.hostingEnabled,
55
+ usesDomain: row.hostingUsesDomain,
56
+ database: row.hostingDatabase,
57
+ tunnel: row.hostingTunnel,
58
+ gitBranch: row.hostingGitBranch,
59
+ buildPack: row.hostingBuildPack,
60
+ issuerApps: row.hostingIssuerApps,
61
+ },
53
62
  sso: { enabled: row.ssoEnabled },
54
63
  guests: { enabled: row.guestsEnabled },
55
64
  solo: { enabled: row.soloEnabled },
@@ -60,6 +69,8 @@ function rowToOrg(row) {
60
69
  adoptedApp: row.adoptedAppForm
61
70
  ? { form: row.adoptedAppForm }
62
71
  : null,
72
+ owner: rowToOwner(row),
73
+ test: row.isTest,
63
74
  };
64
75
  }
65
76
  function orgToRow(org) {
@@ -75,6 +86,13 @@ function orgToRow(org) {
75
86
  issuerUsesDomain: org.issuer.usesDomain,
76
87
  siteEnabled: org.site.enabled,
77
88
  siteUsesDomain: org.site.usesDomain,
89
+ hostingEnabled: org.hosting.enabled,
90
+ hostingUsesDomain: org.hosting.usesDomain,
91
+ hostingDatabase: org.hosting.database,
92
+ hostingTunnel: org.hosting.tunnel,
93
+ hostingGitBranch: org.hosting.gitBranch,
94
+ hostingBuildPack: org.hosting.buildPack,
95
+ hostingIssuerApps: org.hosting.issuerApps,
78
96
  ssoEnabled: org.sso.enabled,
79
97
  guestsEnabled: org.guests.enabled,
80
98
  soloEnabled: org.solo.enabled,
@@ -90,6 +108,10 @@ function orgToRow(org) {
90
108
  vatRegistered: org.buyer.vatRegistered,
91
109
  bespokeAppRepo: org.bespokeAppRepo,
92
110
  adoptedAppForm: org.adoptedApp?.form ?? null,
111
+ ownerEmail: org.owner?.email ?? null,
112
+ ownerGivenName: org.owner?.givenName ?? null,
113
+ ownerFamilyName: org.owner?.familyName ?? null,
114
+ isTest: org.test,
93
115
  };
94
116
  }
95
117
  /**
@@ -117,6 +139,17 @@ function rowToBuyer(row) {
117
139
  vatRegistered: row.vatRegistered,
118
140
  };
119
141
  }
142
+ /**
143
+ * Reassembles `DeclaredOrg.owner` from the row's three separate nullable
144
+ * columns -- present only when all three are, same "all parts or none"
145
+ * pattern {@link rowToBuyer} uses for the buyer's address.
146
+ */
147
+ function rowToOwner(row) {
148
+ const { ownerEmail, ownerGivenName, ownerFamilyName } = row;
149
+ if (ownerEmail === null || ownerGivenName === null || ownerFamilyName === null)
150
+ return null;
151
+ return { email: ownerEmail, givenName: ownerGivenName, familyName: ownerFamilyName };
152
+ }
120
153
  /**
121
154
  * `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
122
155
  * edit a {@link DeclaredOrg}, and read and write the {@link AppliedState}
@@ -129,7 +162,9 @@ function rowToBuyer(row) {
129
162
  * on both `create` and `update`. #27 adds a third, narrower rule: storage and
130
163
  * AI spend limits are bigint-safe decimal strings of non-negative integers
131
164
  * (or `null` for uncapped), checked the same way on both `create` and the
132
- * merged result of `update`. The broader "combinations foundry knows are
165
+ * merged result of `update`. #67 adds a fourth (`org.ts`'s
166
+ * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
167
+ * checked the same way. The broader "combinations foundry knows are
133
168
  * impossible are rejected with a reason" line needs a ruleset (domain
134
169
  * requirements per service, `adoptedApp.form`, mail-level transitions…)
135
170
  * that later tickets, not #18, #27 or #28, decide; `update` still merges and
@@ -169,11 +204,27 @@ export function createPostgresDeclaredOrgStore(db) {
169
204
  if (reason)
170
205
  throw new DeclaredOrgError(`buyer identity invalid: ${reason} (#67)`);
171
206
  }
207
+ // #71: same place `requireValidBuyer` checks the buyer identity (#67), on
208
+ // both `create` and the merged result of `update`.
209
+ function requireValidHosting(org) {
210
+ const reason = validateHosting(org.hosting);
211
+ if (reason)
212
+ throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
213
+ }
214
+ // Same place `requireValidHosting` checks hosting, on both `create` and
215
+ // the merged result of `update`.
216
+ function requireValidOwner(org) {
217
+ const reason = validateOwner(org.owner);
218
+ if (reason)
219
+ throw new DeclaredOrgError(`owner invalid: ${reason}`);
220
+ }
172
221
  return {
173
222
  async create(org) {
174
223
  requireValidSlug(org.slug);
175
224
  requireValidLimits(org);
176
225
  requireValidBuyer(org);
226
+ requireValidHosting(org);
227
+ requireValidOwner(org);
177
228
  try {
178
229
  await db.insert(declaredOrgs).values(orgToRow(org));
179
230
  }
@@ -212,6 +263,8 @@ export function createPostgresDeclaredOrgStore(db) {
212
263
  const next = { ...current, ...patch, id: current.id };
213
264
  requireValidLimits(next);
214
265
  requireValidBuyer(next);
266
+ requireValidHosting(next);
267
+ requireValidOwner(next);
215
268
  try {
216
269
  await db
217
270
  .update(declaredOrgs)
@@ -263,5 +316,15 @@ export function createPostgresDeclaredOrgStore(db) {
263
316
  .values(values)
264
317
  .onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values });
265
318
  },
319
+ async delete(id) {
320
+ // Applied state first: `0002_applied_state.sql`'s
321
+ // `foundry_applied_state_org_fkey` references the declared org with no
322
+ // cascade, so deleting the org first would fail while its applied
323
+ // state row still points at it. Both deletes are no-ops (idempotent)
324
+ // when the row is already gone, matching this method's own doc
325
+ // comment on `../store.ts`.
326
+ await db.delete(foundryAppliedState).where(eq(foundryAppliedState.orgId, id));
327
+ await db.delete(declaredOrgs).where(eq(declaredOrgs.id, id));
328
+ },
266
329
  };
267
330
  }
@@ -1,3 +1,4 @@
1
+ import type { IssuerApp } from '../org.js';
1
2
  import type { Checkpoint } from '../state.js';
2
3
  /**
3
4
  * Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
@@ -196,6 +197,127 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
196
197
  identity: undefined;
197
198
  generated: undefined;
198
199
  }, {}, {}>;
200
+ hostingEnabled: import("drizzle-orm/pg-core").PgColumn<{
201
+ name: "hosting_enabled";
202
+ tableName: "foundry_declared_orgs";
203
+ dataType: "boolean";
204
+ columnType: "PgBoolean";
205
+ data: boolean;
206
+ driverParam: boolean;
207
+ notNull: true;
208
+ hasDefault: true;
209
+ isPrimaryKey: false;
210
+ isAutoincrement: false;
211
+ hasRuntimeDefault: false;
212
+ enumValues: undefined;
213
+ baseColumn: never;
214
+ identity: undefined;
215
+ generated: undefined;
216
+ }, {}, {}>;
217
+ hostingUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
218
+ name: "hosting_uses_domain";
219
+ tableName: "foundry_declared_orgs";
220
+ dataType: "boolean";
221
+ columnType: "PgBoolean";
222
+ data: boolean;
223
+ driverParam: boolean;
224
+ notNull: true;
225
+ hasDefault: true;
226
+ isPrimaryKey: false;
227
+ isAutoincrement: false;
228
+ hasRuntimeDefault: false;
229
+ enumValues: undefined;
230
+ baseColumn: never;
231
+ identity: undefined;
232
+ generated: undefined;
233
+ }, {}, {}>;
234
+ hostingDatabase: import("drizzle-orm/pg-core").PgColumn<{
235
+ name: "hosting_database";
236
+ tableName: "foundry_declared_orgs";
237
+ dataType: "boolean";
238
+ columnType: "PgBoolean";
239
+ data: boolean;
240
+ driverParam: boolean;
241
+ notNull: true;
242
+ hasDefault: true;
243
+ isPrimaryKey: false;
244
+ isAutoincrement: false;
245
+ hasRuntimeDefault: false;
246
+ enumValues: undefined;
247
+ baseColumn: never;
248
+ identity: undefined;
249
+ generated: undefined;
250
+ }, {}, {}>;
251
+ hostingTunnel: import("drizzle-orm/pg-core").PgColumn<{
252
+ name: "hosting_tunnel";
253
+ tableName: "foundry_declared_orgs";
254
+ dataType: "boolean";
255
+ columnType: "PgBoolean";
256
+ data: boolean;
257
+ driverParam: boolean;
258
+ notNull: true;
259
+ hasDefault: true;
260
+ isPrimaryKey: false;
261
+ isAutoincrement: false;
262
+ hasRuntimeDefault: false;
263
+ enumValues: undefined;
264
+ baseColumn: never;
265
+ identity: undefined;
266
+ generated: undefined;
267
+ }, {}, {}>;
268
+ hostingGitBranch: import("drizzle-orm/pg-core").PgColumn<{
269
+ name: "hosting_git_branch";
270
+ tableName: "foundry_declared_orgs";
271
+ dataType: "string";
272
+ columnType: "PgText";
273
+ data: string;
274
+ driverParam: string;
275
+ notNull: true;
276
+ hasDefault: true;
277
+ isPrimaryKey: false;
278
+ isAutoincrement: false;
279
+ hasRuntimeDefault: false;
280
+ enumValues: [string, ...string[]];
281
+ baseColumn: never;
282
+ identity: undefined;
283
+ generated: undefined;
284
+ }, {}, {}>;
285
+ hostingBuildPack: import("drizzle-orm/pg-core").PgColumn<{
286
+ name: "hosting_build_pack";
287
+ tableName: "foundry_declared_orgs";
288
+ dataType: "string";
289
+ columnType: "PgText";
290
+ data: string;
291
+ driverParam: string;
292
+ notNull: true;
293
+ hasDefault: true;
294
+ isPrimaryKey: false;
295
+ isAutoincrement: false;
296
+ hasRuntimeDefault: false;
297
+ enumValues: [string, ...string[]];
298
+ baseColumn: never;
299
+ identity: undefined;
300
+ generated: undefined;
301
+ }, {}, {}>;
302
+ hostingIssuerApps: import("drizzle-orm/pg-core").PgColumn<{
303
+ name: "hosting_issuer_apps";
304
+ tableName: "foundry_declared_orgs";
305
+ dataType: "json";
306
+ columnType: "PgJsonb";
307
+ data: IssuerApp[];
308
+ driverParam: unknown;
309
+ notNull: true;
310
+ hasDefault: true;
311
+ isPrimaryKey: false;
312
+ isAutoincrement: false;
313
+ hasRuntimeDefault: false;
314
+ enumValues: undefined;
315
+ baseColumn: never;
316
+ identity: undefined;
317
+ generated: undefined;
318
+ }, {}, {
319
+ $type: IssuerApp[];
320
+ }>;
199
321
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
200
322
  name: "sso_enabled";
201
323
  tableName: "foundry_declared_orgs";
@@ -451,6 +573,74 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
451
573
  identity: undefined;
452
574
  generated: undefined;
453
575
  }, {}, {}>;
576
+ ownerEmail: import("drizzle-orm/pg-core").PgColumn<{
577
+ name: "owner_email";
578
+ tableName: "foundry_declared_orgs";
579
+ dataType: "string";
580
+ columnType: "PgText";
581
+ data: string;
582
+ driverParam: string;
583
+ notNull: false;
584
+ hasDefault: false;
585
+ isPrimaryKey: false;
586
+ isAutoincrement: false;
587
+ hasRuntimeDefault: false;
588
+ enumValues: [string, ...string[]];
589
+ baseColumn: never;
590
+ identity: undefined;
591
+ generated: undefined;
592
+ }, {}, {}>;
593
+ ownerGivenName: import("drizzle-orm/pg-core").PgColumn<{
594
+ name: "owner_given_name";
595
+ tableName: "foundry_declared_orgs";
596
+ dataType: "string";
597
+ columnType: "PgText";
598
+ data: string;
599
+ driverParam: string;
600
+ notNull: false;
601
+ hasDefault: false;
602
+ isPrimaryKey: false;
603
+ isAutoincrement: false;
604
+ hasRuntimeDefault: false;
605
+ enumValues: [string, ...string[]];
606
+ baseColumn: never;
607
+ identity: undefined;
608
+ generated: undefined;
609
+ }, {}, {}>;
610
+ ownerFamilyName: import("drizzle-orm/pg-core").PgColumn<{
611
+ name: "owner_family_name";
612
+ tableName: "foundry_declared_orgs";
613
+ dataType: "string";
614
+ columnType: "PgText";
615
+ data: string;
616
+ driverParam: string;
617
+ notNull: false;
618
+ hasDefault: false;
619
+ isPrimaryKey: false;
620
+ isAutoincrement: false;
621
+ hasRuntimeDefault: false;
622
+ enumValues: [string, ...string[]];
623
+ baseColumn: never;
624
+ identity: undefined;
625
+ generated: undefined;
626
+ }, {}, {}>;
627
+ isTest: import("drizzle-orm/pg-core").PgColumn<{
628
+ name: "is_test";
629
+ tableName: "foundry_declared_orgs";
630
+ dataType: "boolean";
631
+ columnType: "PgBoolean";
632
+ data: boolean;
633
+ driverParam: boolean;
634
+ notNull: true;
635
+ hasDefault: true;
636
+ isPrimaryKey: false;
637
+ isAutoincrement: false;
638
+ hasRuntimeDefault: false;
639
+ enumValues: undefined;
640
+ baseColumn: never;
641
+ identity: undefined;
642
+ generated: undefined;
643
+ }, {}, {}>;
454
644
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
455
645
  name: "created_at";
456
646
  tableName: "foundry_declared_orgs";
@@ -18,6 +18,14 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
18
18
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
19
19
  siteEnabled: boolean('site_enabled').notNull().default(false),
20
20
  siteUsesDomain: boolean('site_uses_domain').notNull().default(false),
21
+ // HostingConfig (org.ts, #71). A brand new org has no hosted app.
22
+ hostingEnabled: boolean('hosting_enabled').notNull().default(false),
23
+ hostingUsesDomain: boolean('hosting_uses_domain').notNull().default(false),
24
+ hostingDatabase: boolean('hosting_database').notNull().default(false),
25
+ hostingTunnel: boolean('hosting_tunnel').notNull().default(false),
26
+ hostingGitBranch: text('hosting_git_branch').notNull().default('main'),
27
+ hostingBuildPack: text('hosting_build_pack').notNull().default('nixpacks'),
28
+ hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
21
29
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
22
30
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
23
31
  soloEnabled: boolean('solo_enabled').notNull().default(false),
@@ -40,6 +48,16 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
40
48
  vatRegistered: boolean('vat_registered'),
41
49
  bespokeAppRepo: text('bespoke_app_repo'),
42
50
  adoptedAppForm: text('adopted_app_form'),
51
+ // OwnerIdentity (org.ts): the org's owner, invited by ZITADEL email. All
52
+ // three nullable -- a brand new org has none of them, reassembled as one
53
+ // object only once all three are non-null (same shape as BuyerIdentity's
54
+ // own contact fields above).
55
+ ownerEmail: text('owner_email'),
56
+ ownerGivenName: text('owner_given_name'),
57
+ ownerFamilyName: text('owner_family_name'),
58
+ // `test: boolean` (org.ts): only a test org can be destroyed. A brand new
59
+ // org defaults to real (false).
60
+ isTest: boolean('is_test').notNull().default(false),
43
61
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
44
62
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
45
63
  });
package/dist/store.d.ts CHANGED
@@ -20,4 +20,14 @@ export interface DeclaredOrgStore {
20
20
  update(id: OrgId, patch: Partial<DeclaredOrg>): Promise<DeclaredOrg>;
21
21
  getAppliedState(id: OrgId): Promise<AppliedState | null>;
22
22
  putAppliedState(state: AppliedState): Promise<void>;
23
+ /**
24
+ * Removes the declared org and its applied state together. Called only by
25
+ * `FoundryPlanApply.destroy` (`plan/plan-apply.ts`), once every resource it
26
+ * recorded for this org has already been deleted from Coolify, Cloudflare
27
+ * and ZITADEL -- this is the last step, not the first. Idempotent: deleting
28
+ * an id that does not exist (or no longer has applied state) is a no-op,
29
+ * not an error, so a retried destroy that reaches this step twice never
30
+ * throws.
31
+ */
32
+ delete(id: OrgId): Promise<void>;
23
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,9 +9,7 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "type": "module",
12
- "files": [
13
- "dist"
14
- ],
12
+ "files": ["dist"],
15
13
  "exports": {
16
14
  ".": {
17
15
  "types": "./dist/index.d.ts",
@@ -27,11 +25,16 @@
27
25
  "engines": {
28
26
  "node": ">=22.0.0"
29
27
  },
28
+ "scripts": {
29
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && find src/migrations -maxdepth 1 -name '*.sql' -exec cp {} dist/migrations/ \\;",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run"
32
+ },
30
33
  "peerDependencies": {
31
34
  "drizzle-orm": ">=0.45.2"
32
35
  },
33
36
  "dependencies": {
34
- "@wtfalch/providers": "0.1.0"
37
+ "@wtfalch/providers": "0.5.0"
35
38
  },
36
39
  "devDependencies": {
37
40
  "@electric-sql/pglite": "^0.5.8",
@@ -40,10 +43,5 @@
40
43
  "postgres": "3.4.9",
41
44
  "typescript": "^5.9.0",
42
45
  "vitest": "^4.1.6"
43
- },
44
- "scripts": {
45
- "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && find src/migrations -maxdepth 1 -name '*.sql' -exec cp {} dist/migrations/ \\;",
46
- "typecheck": "tsc --noEmit",
47
- "test": "vitest run"
48
46
  }
49
- }
47
+ }