@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,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
|
+
}
|