@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/dist/apply.d.ts +27 -0
  3. package/dist/apply.js +1 -0
  4. package/dist/index.d.ts +17 -0
  5. package/dist/index.js +7 -0
  6. package/dist/migrations/0001_declared_org.sql +61 -0
  7. package/dist/migrations/0002_applied_state.sql +43 -0
  8. package/dist/migrations/0003_foundry_runs.sql +53 -0
  9. package/dist/migrations/0004_slug_unique.sql +26 -0
  10. package/dist/migrations/0005_spend_limits.sql +34 -0
  11. package/dist/migrations/0006_buyer_identity.sql +31 -0
  12. package/dist/migrations/0007_site.sql +18 -0
  13. package/dist/org.d.ts +276 -0
  14. package/dist/org.js +29 -0
  15. package/dist/plan/diff.d.ts +52 -0
  16. package/dist/plan/diff.js +302 -0
  17. package/dist/plan/index.d.ts +8 -0
  18. package/dist/plan/index.js +4 -0
  19. package/dist/plan/live.d.ts +62 -0
  20. package/dist/plan/live.js +12 -0
  21. package/dist/plan/plan-apply.d.ts +107 -0
  22. package/dist/plan/plan-apply.js +172 -0
  23. package/dist/plan/providers-readers.d.ts +86 -0
  24. package/dist/plan/providers-readers.js +83 -0
  25. package/dist/plan.d.ts +50 -0
  26. package/dist/plan.js +1 -0
  27. package/dist/runner/db.d.ts +8 -0
  28. package/dist/runner/db.js +1 -0
  29. package/dist/runner/errors.d.ts +37 -0
  30. package/dist/runner/errors.js +57 -0
  31. package/dist/runner/index.d.ts +4 -0
  32. package/dist/runner/index.js +2 -0
  33. package/dist/runner/runner.d.ts +6 -0
  34. package/dist/runner/runner.js +137 -0
  35. package/dist/runner/schema.d.ts +163 -0
  36. package/dist/runner/schema.js +25 -0
  37. package/dist/runner/types.d.ts +97 -0
  38. package/dist/runner/types.js +1 -0
  39. package/dist/slug.d.ts +38 -0
  40. package/dist/slug.js +141 -0
  41. package/dist/state.d.ts +63 -0
  42. package/dist/state.js +1 -0
  43. package/dist/store/postgres.d.ts +43 -0
  44. package/dist/store/postgres.js +267 -0
  45. package/dist/store/schema.d.ts +615 -0
  46. package/dist/store/schema.js +59 -0
  47. package/dist/store.d.ts +23 -0
  48. package/dist/store.js +1 -0
  49. package/package.json +49 -0
@@ -0,0 +1,267 @@
1
+ import { eq, sql } from 'drizzle-orm';
2
+ import { validateBuyerIdentity } from '../org.js';
3
+ import { validateSlug } from '../slug.js';
4
+ import { declaredOrgs, foundryAppliedState, } from './schema.js';
5
+ const UNIQUE_VIOLATION = '23505';
6
+ /** #27: a bigint-safe decimal string of a non-negative integer -- no sign, no decimal point, no leading zeros (so "010" never diffs against a live "10"). */
7
+ const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
8
+ /**
9
+ * True for a Postgres unique-violation, however the driver hands it back --
10
+ * directly (PGlite in this package's own tests) or wrapped in drizzle-orm's
11
+ * own `DrizzleQueryError`, whose `cause` carries the raw driver error
12
+ * (postgres-js in a host). Checked by Postgres error code, never by message
13
+ * text, since message text is not a contract. Same pattern as
14
+ * `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
15
+ * here rather than shared, since neither module depends on the other.
16
+ */
17
+ function isUniqueViolation(error) {
18
+ const code = error?.code;
19
+ if (code === UNIQUE_VIOLATION)
20
+ return true;
21
+ const cause = error?.cause;
22
+ return cause?.code === UNIQUE_VIOLATION;
23
+ }
24
+ /**
25
+ * Thrown by `create` and `update` when the store refuses a write: an id
26
+ * that already exists, an id that does not, a slug that fails
27
+ * `../slug.ts`'s `validateSlug` (#28: invalid DNS label, reserved, or an
28
+ * environment-suffix shape), a slug already taken by another org (#28:
29
+ * checked by the unique index `0004_slug_unique.sql` adds, whether caught
30
+ * ahead of time by a pre-check or by a race this store maps back to the
31
+ * same error) or — the one impossible combination #18 implements — a slug
32
+ * change once the org has mail. `store.ts`'s `update` doc requires a caller
33
+ * to be "ready for this to throw rather than silently drop a field"; this
34
+ * is that throw.
35
+ */
36
+ export class DeclaredOrgError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = 'DeclaredOrgError';
40
+ }
41
+ }
42
+ function rowToOrg(row) {
43
+ return {
44
+ id: row.id,
45
+ slug: row.slug,
46
+ displayName: row.displayName,
47
+ domain: row.domain,
48
+ mail: { mode: row.mailMode, usesDomain: row.mailUsesDomain },
49
+ chat: { usesDomain: row.chatUsesDomain },
50
+ calls: { usesDomain: row.callsUsesDomain },
51
+ issuer: { usesDomain: row.issuerUsesDomain },
52
+ site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
53
+ sso: { enabled: row.ssoEnabled },
54
+ guests: { enabled: row.guestsEnabled },
55
+ solo: { enabled: row.soloEnabled },
56
+ storage: { limitBytes: row.storageLimitBytes, limitMicros: row.storageLimitMicros },
57
+ ai: { limitMicros: row.aiLimitMicros },
58
+ buyer: rowToBuyer(row),
59
+ bespokeAppRepo: row.bespokeAppRepo,
60
+ adoptedApp: row.adoptedAppForm
61
+ ? { form: row.adoptedAppForm }
62
+ : null,
63
+ };
64
+ }
65
+ function orgToRow(org) {
66
+ return {
67
+ id: org.id,
68
+ slug: org.slug,
69
+ displayName: org.displayName,
70
+ domain: org.domain,
71
+ mailMode: org.mail.mode,
72
+ mailUsesDomain: org.mail.usesDomain,
73
+ chatUsesDomain: org.chat.usesDomain,
74
+ callsUsesDomain: org.calls.usesDomain,
75
+ issuerUsesDomain: org.issuer.usesDomain,
76
+ siteEnabled: org.site.enabled,
77
+ siteUsesDomain: org.site.usesDomain,
78
+ ssoEnabled: org.sso.enabled,
79
+ guestsEnabled: org.guests.enabled,
80
+ soloEnabled: org.solo.enabled,
81
+ storageLimitBytes: org.storage.limitBytes,
82
+ storageLimitMicros: org.storage.limitMicros,
83
+ aiLimitMicros: org.ai.limitMicros,
84
+ legalName: org.buyer.legalName,
85
+ orgNumber: org.buyer.orgNumber,
86
+ addressStreet: org.buyer.address?.street ?? null,
87
+ addressPostalCode: org.buyer.address?.postalCode ?? null,
88
+ addressCity: org.buyer.address?.city ?? null,
89
+ addressCountry: org.buyer.address?.country ?? null,
90
+ vatRegistered: org.buyer.vatRegistered,
91
+ bespokeAppRepo: org.bespokeAppRepo,
92
+ adoptedAppForm: org.adoptedApp?.form ?? null,
93
+ };
94
+ }
95
+ /**
96
+ * #67: reassembles `BuyerIdentity.address` from the row's four separate
97
+ * nullable columns -- present only when all four are, matching
98
+ * `validateBuyerIdentity`'s "all four parts or none".
99
+ */
100
+ function rowToBuyer(row) {
101
+ const { addressStreet, addressPostalCode, addressCity, addressCountry } = row;
102
+ const address = addressStreet !== null &&
103
+ addressPostalCode !== null &&
104
+ addressCity !== null &&
105
+ addressCountry !== null
106
+ ? {
107
+ street: addressStreet,
108
+ postalCode: addressPostalCode,
109
+ city: addressCity,
110
+ country: addressCountry,
111
+ }
112
+ : null;
113
+ return {
114
+ legalName: row.legalName,
115
+ orgNumber: row.orgNumber,
116
+ address,
117
+ vatRegistered: row.vatRegistered,
118
+ };
119
+ }
120
+ /**
121
+ * `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
122
+ * edit a {@link DeclaredOrg}, and read and write the {@link AppliedState}
123
+ * next to it, both in foundry's own database per #10 (resolved) — never git.
124
+ *
125
+ * Validation here is deliberately narrow. #18's "Done when" list names one
126
+ * impossible combination in full — the slug freezes once the org has mail
127
+ * (#7 corrections 3) — and #28 adds one more, in full: the slug is a
128
+ * validated (`../slug.ts`), globally unique, non-reserved DNS label, checked
129
+ * on both `create` and `update`. #27 adds a third, narrower rule: storage and
130
+ * AI spend limits are bigint-safe decimal strings of non-negative integers
131
+ * (or `null` for uncapped), checked the same way on both `create` and the
132
+ * merged result of `update`. The broader "combinations foundry knows are
133
+ * impossible are rejected with a reason" line needs a ruleset (domain
134
+ * requirements per service, `adoptedApp.form`, mail-level transitions…)
135
+ * that later tickets, not #18, #27 or #28, decide; `update` still merges and
136
+ * stores any other patch as given.
137
+ */
138
+ export function createPostgresDeclaredOrgStore(db) {
139
+ async function requireRow(id) {
140
+ const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
141
+ if (!row)
142
+ throw new DeclaredOrgError(`no declared org ${id}`);
143
+ return row;
144
+ }
145
+ function requireValidSlug(slug) {
146
+ const rejection = validateSlug(slug);
147
+ if (rejection)
148
+ throw new DeclaredOrgError(rejection.message);
149
+ }
150
+ function requireValidLimit(fieldName, value) {
151
+ if (value !== null && !NON_NEGATIVE_INTEGER.test(value)) {
152
+ throw new DeclaredOrgError(`${fieldName} '${value}' must be a bigint-safe decimal string of a non-negative integer, or null for uncapped (#27)`);
153
+ }
154
+ }
155
+ // #27: storage and AI limits are bigint-safe decimal strings of
156
+ // non-negative integers, or `null` for uncapped -- never converted to a
157
+ // number, since a limit can exceed Number.MAX_SAFE_INTEGER. Checked here,
158
+ // the same place `requireValidSlug` checks the slug (#28), on both
159
+ // `create` and the merged result of `update`.
160
+ function requireValidLimits(org) {
161
+ requireValidLimit('storage.limitBytes', org.storage.limitBytes);
162
+ requireValidLimit('storage.limitMicros', org.storage.limitMicros);
163
+ requireValidLimit('ai.limitMicros', org.ai.limitMicros);
164
+ }
165
+ // #67: same place `requireValidLimits` checks the spend limits (#27), on
166
+ // both `create` and the merged result of `update`.
167
+ function requireValidBuyer(org) {
168
+ const reason = validateBuyerIdentity(org.buyer);
169
+ if (reason)
170
+ throw new DeclaredOrgError(`buyer identity invalid: ${reason} (#67)`);
171
+ }
172
+ return {
173
+ async create(org) {
174
+ requireValidSlug(org.slug);
175
+ requireValidLimits(org);
176
+ requireValidBuyer(org);
177
+ try {
178
+ await db.insert(declaredOrgs).values(orgToRow(org));
179
+ }
180
+ catch (error) {
181
+ // The pre-check above already refuses a slug this process knows is
182
+ // taken (nothing does, on a fresh insert) -- this catches the race
183
+ // the pre-check cannot: two concurrent creates for the same slug,
184
+ // caught by `0004_slug_unique.sql`'s unique index and reported
185
+ // through the same error a pre-check would have thrown (#28).
186
+ if (isUniqueViolation(error))
187
+ throw new DeclaredOrgError(`slug '${org.slug}' is already taken by another org`);
188
+ throw error;
189
+ }
190
+ },
191
+ async get(id) {
192
+ const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
193
+ return row ? rowToOrg(row) : null;
194
+ },
195
+ async update(id, patch) {
196
+ const current = rowToOrg(await requireRow(id));
197
+ // The slug freeze (#7 corrections 3): once mail exists, the mail
198
+ // subdomain already reads the slug, so the slug this update would
199
+ // leave in place — not the one it started from — is what decides.
200
+ // Turning mail on and changing the slug in the same patch is treated
201
+ // as "now has mail", not as a window that slips the change through.
202
+ const effectiveMailMode = patch.mail?.mode ?? current.mail.mode;
203
+ if (patch.slug !== undefined && patch.slug !== current.slug && effectiveMailMode !== 'off') {
204
+ throw new DeclaredOrgError(`org ${id}: slug is frozen once the org has mail (mode '${effectiveMailMode}')`);
205
+ }
206
+ // #28: a changed slug is validated the same way `create` validates
207
+ // one -- unchanged, it was already valid when it was created (or
208
+ // grandfathered before #28 existed), so re-checking it here would
209
+ // just be re-litigating a write this update isn't making.
210
+ if (patch.slug !== undefined && patch.slug !== current.slug)
211
+ requireValidSlug(patch.slug);
212
+ const next = { ...current, ...patch, id: current.id };
213
+ requireValidLimits(next);
214
+ requireValidBuyer(next);
215
+ try {
216
+ await db
217
+ .update(declaredOrgs)
218
+ .set({ ...orgToRow(next), updatedAt: sql `now()` })
219
+ .where(eq(declaredOrgs.id, id));
220
+ }
221
+ catch (error) {
222
+ // Same race as `create`: another org's concurrent write took this
223
+ // slug between the pre-check above and this statement (#28).
224
+ if (isUniqueViolation(error))
225
+ throw new DeclaredOrgError(`slug '${next.slug}' is already taken by another org`);
226
+ throw error;
227
+ }
228
+ return next;
229
+ },
230
+ async getAppliedState(id) {
231
+ const [row] = await db
232
+ .select()
233
+ .from(foundryAppliedState)
234
+ .where(eq(foundryAppliedState.orgId, id))
235
+ .limit(1);
236
+ if (!row)
237
+ return null;
238
+ const state = {
239
+ orgId: row.orgId,
240
+ serviceIds: row.serviceIds,
241
+ checkpoints: row.checkpoints,
242
+ failedFields: row.failedFields,
243
+ lastApplied: row.lastAppliedAt && row.lastAppliedDeclaredOrgVersion
244
+ ? {
245
+ at: row.lastAppliedAt.toISOString(),
246
+ declaredOrgVersion: row.lastAppliedDeclaredOrgVersion,
247
+ }
248
+ : null,
249
+ };
250
+ return state;
251
+ },
252
+ async putAppliedState(state) {
253
+ const values = {
254
+ orgId: state.orgId,
255
+ serviceIds: state.serviceIds,
256
+ checkpoints: state.checkpoints,
257
+ failedFields: state.failedFields,
258
+ lastAppliedAt: state.lastApplied ? new Date(state.lastApplied.at) : null,
259
+ lastAppliedDeclaredOrgVersion: state.lastApplied?.declaredOrgVersion ?? null,
260
+ };
261
+ await db
262
+ .insert(foundryAppliedState)
263
+ .values(values)
264
+ .onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values });
265
+ },
266
+ };
267
+ }