@wtfalch/foundry 0.42.1 → 0.44.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 (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -0,0 +1,438 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { validateSlug } from './slug.js';
3
+ /** The compatibility flags a Worker app may ask for. */
4
+ export const WORKER_APP_COMPATIBILITY_FLAGS = new Set(['nodejs_compat']);
5
+ export const WORKER_APP_INSTANCE_TYPES = new Set([
6
+ 'lite',
7
+ 'basic',
8
+ 'standard-1',
9
+ 'standard-2',
10
+ 'standard-3',
11
+ 'standard-4',
12
+ ]);
13
+ /** The most instances one container class may scale to. */
14
+ export const WORKER_APP_MAX_INSTANCES = 100;
15
+ /** The secret that carries a `keysCustody` source: the custody Worker credential. */
16
+ export const WORKER_APP_KEYS_SECRET = 'KEYS_CREDENTIAL';
17
+ export const WORKER_APP_KEYS_URL_VAR = 'KEYS_URL';
18
+ /**
19
+ * Names foundry writes into a Worker app's environment itself, so a
20
+ * declaration may not use them as a binding, secret or var.
21
+ */
22
+ export const WORKER_APP_RESERVED_NAMES = /^(AUTH_.*|KEYS_.*|MCP_.*|HYPERDRIVE)$/;
23
+ /** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
24
+ export const entryHasDatabase = (entry) => entry.database || Boolean(entry.workerApp?.database);
25
+ /** The Hyperdrive binding of a Worker app with `database`. */
26
+ export const WORKER_APP_HYPERDRIVE_BINDING = 'HYPERDRIVE';
27
+ /** The most tables `database.appendOnly` may name, and the shape of one name (`table` or `schema.table`). */
28
+ export const WORKER_APP_APPEND_ONLY_MAX = 100;
29
+ const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}(\.[A-Za-z_][A-Za-z0-9_]{0,62})?$/;
30
+ /** The custody Worker's host id shape for a derived (app) host, and its limit. */
31
+ const HOST_ID = /^app-[a-z0-9]+(-[a-z0-9]+)*$/;
32
+ const HOST_ID_MAX_LENGTH = 64;
33
+ /** Hex characters of the org id's sha256 in a host id. */
34
+ export const WORKER_APP_HOST_ID_HASH_LENGTH = 12;
35
+ /**
36
+ * The custody host id of a Worker app: `app-<12 hex of sha256(org id)>-<entry key>`.
37
+ * It derives from the org id, never the slug, so a slug rename keeps it, and
38
+ * two orgs cannot share one (their hashes differ). The entry key is unique
39
+ * within an org. Null when the result breaks the custody pattern
40
+ * `^app-[a-z0-9]+(-[a-z0-9]+)*$`, 64 characters, or ends in `-next`.
41
+ */
42
+ export function workerAppHostId(orgId, entryKey) {
43
+ const hash = createHash('sha256')
44
+ .update(orgId)
45
+ .digest('hex')
46
+ .slice(0, WORKER_APP_HOST_ID_HASH_LENGTH);
47
+ const id = `app-${hash}-${entryKey}`.toLowerCase();
48
+ return HOST_ID.test(id) && id.length <= HOST_ID_MAX_LENGTH && !id.endsWith('-next') ? id : null;
49
+ }
50
+ /** The script name ends in this; the entry key's budget against Cloudflare's 63 characters follows from it. */
51
+ export const WORKER_APP_NAME_SUFFIX = '-app';
52
+ const WORKER_NAME_MAX_LENGTH = 63;
53
+ const WORKER_NAME_FIXED_LENGTH = 8 + 1 + WORKER_APP_NAME_SUFFIX.length;
54
+ const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
55
+ const RELEASE_ASSET_URL = /^https:\/\/github\.com\/wtfalch\/([A-Za-z0-9._-]+)\/releases\/download\/([A-Za-z0-9._+-]+)\/([A-Za-z0-9._+-]+)$/;
56
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
57
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
58
+ const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
59
+ const CLASS_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
60
+ const MIGRATION_TAG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
61
+ const IMAGE_REPOSITORY = /^[a-z0-9]+([._-][a-z0-9]+)*(:[0-9]+)?(\/[a-z0-9]+([._-][a-z0-9]+)*)+$/;
62
+ const COMPAT_DATE = /^\d{4}-\d{2}-\d{2}$/;
63
+ const SECRET_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
64
+ const VAR_MAX_LENGTH = 1000;
65
+ /**
66
+ * Checks an artifact reference: an https URL with no userinfo, query or
67
+ * fragment (those end up verbatim in recorded scope ids) and a lowercase hex
68
+ * sha256. Shared by `worker` and `workerApp`.
69
+ */
70
+ export function validateArtifact(at, artifactUrl, sha256, repo = null) {
71
+ let url;
72
+ try {
73
+ url = new URL(artifactUrl);
74
+ }
75
+ catch {
76
+ return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
77
+ }
78
+ if (url.protocol !== 'https:')
79
+ return `${at} artifactUrl must be an https:// URL`;
80
+ if (url.username || url.password) {
81
+ return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
82
+ }
83
+ if (url.search) {
84
+ return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
85
+ }
86
+ // `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
87
+ if (artifactUrl.includes('#')) {
88
+ return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
89
+ }
90
+ // Foundry's host fetches this URL with a GitHub token, so it names a release
91
+ // asset of the org's own repo and nothing else: no other host, port or path.
92
+ const asset = RELEASE_ASSET_URL.exec(artifactUrl);
93
+ const segment = (value) => value !== undefined && !/^\.+$/.test(value);
94
+ const declared = repo?.slice(repo.indexOf('/') + 1);
95
+ if (!asset || !segment(asset[1]) || !segment(asset[2]) || !segment(asset[3])) {
96
+ return `${at} artifactUrl must be https://github.com/wtfalch/<repo>/releases/download/<tag>/<file>: foundry fetches it with a GitHub token, so it names a release asset of a wtfalch repo and nothing else`;
97
+ }
98
+ if (declared !== undefined && declared !== '' && asset[1] !== declared) {
99
+ return `${at} artifactUrl must be a release asset of the entry's own repo '${repo}'`;
100
+ }
101
+ if (!SHA256_HEX.test(sha256))
102
+ return `${at} artifactSha256 must be 64 lowercase hex characters`;
103
+ return null;
104
+ }
105
+ /**
106
+ * The one exact name a Worker app is served at: `<host>.<domain>` or the apex
107
+ * on the org's own domain, else `<slug>-<key>.oikos.host` (one label under
108
+ * `oikos.host`, so the free Universal certificate covers it), and
109
+ * `<slug>.oikos.host` for the entry keyed `app`.
110
+ */
111
+ export function workerAppHostnameOf(org, key, host) {
112
+ if (org.usesDomain && org.domain)
113
+ return host === '@' ? org.domain : `${host}.${org.domain}`;
114
+ return key === 'app' ? `${org.slug}.oikos.host` : `${org.slug}-${key}.oikos.host`;
115
+ }
116
+ /** True for a real calendar date written `YYYY-MM-DD`. */
117
+ function isCalendarDate(value) {
118
+ if (!COMPAT_DATE.test(value))
119
+ return false;
120
+ const parsed = new Date(`${value}T00:00:00Z`);
121
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value);
122
+ }
123
+ /**
124
+ * The Durable Object classes the migrations leave behind, folded in order:
125
+ * created, then removed, then renamed. A rename of a class that does not exist
126
+ * at that point is an error.
127
+ */
128
+ export function foldMigrationClasses(migrations) {
129
+ const classes = new Set();
130
+ for (const migration of migrations) {
131
+ for (const name of migration.newSqliteClasses ?? []) {
132
+ if (classes.has(name)) {
133
+ return {
134
+ error: `migration '${migration.tag}' creates class '${name}', which already exists`,
135
+ };
136
+ }
137
+ classes.add(name);
138
+ }
139
+ for (const name of migration.deletedClasses ?? []) {
140
+ if (!classes.delete(name)) {
141
+ return {
142
+ error: `migration '${migration.tag}' deletes class '${name}', which does not exist`,
143
+ };
144
+ }
145
+ }
146
+ for (const { from, to } of migration.renamedClasses ?? []) {
147
+ if (!classes.delete(from)) {
148
+ return {
149
+ error: `migration '${migration.tag}' renames class '${from}', which does not exist`,
150
+ };
151
+ }
152
+ if (classes.has(to)) {
153
+ return {
154
+ error: `migration '${migration.tag}' renames '${from}' to '${to}', which already exists`,
155
+ };
156
+ }
157
+ classes.add(to);
158
+ }
159
+ }
160
+ return { classes };
161
+ }
162
+ /**
163
+ * `workerApp`: exclusive with `worker` and a Coolify repo, no Coolify-only
164
+ * setting, a Durable Object and container declaration Cloudflare will accept,
165
+ * names that cannot clash with what foundry delivers, and one exact host.
166
+ * `null` when valid. Whether a migration was already applied is a fact of the
167
+ * live system; the planner checks that (`plan/worker-app.ts`).
168
+ */
169
+ export function validateEntryWorkerApp(entry, hosting, names) {
170
+ const app = entry.workerApp;
171
+ if (!app)
172
+ return null;
173
+ const at = `hosting.entries '${entry.key}' workerApp`;
174
+ if (!hosting.enabled)
175
+ return `${at} requires hosting.enabled`;
176
+ if (entry.worker)
177
+ return `${at} is exclusive with worker: declare one of them`;
178
+ if (entry.repo !== null) {
179
+ return `${at} is exclusive with a Coolify repo: an entry that declares workerApp has repo null`;
180
+ }
181
+ if (entry.hostnames.length > 0) {
182
+ return `${at} names its host in workerApp.host, so hostnames must be empty`;
183
+ }
184
+ if (entry.database) {
185
+ return `${at} has no Coolify app, so the entry cannot set database: declare workerApp.database instead`;
186
+ }
187
+ if ((entry.volumes?.length ?? 0) > 0)
188
+ return `${at} has no Coolify app, so it cannot declare volumes`;
189
+ if (entry.gates.length > 0)
190
+ return `${at} has no Coolify app, so it cannot declare gates`;
191
+ if (Object.keys(entry.env).length > 0) {
192
+ return `${at} has no Coolify app, so env does not apply: use workerApp.vars or workerApp.secrets`;
193
+ }
194
+ const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth');
195
+ if (unsupported)
196
+ return `${at} supports uses.auth only, not uses.${unsupported}`;
197
+ if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
198
+ return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "${WORKER_APP_NAME_SUFFIX}") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
199
+ }
200
+ const invalidArtifact = validateArtifact(at, app.artifactUrl, app.artifactSha256, entry.repo);
201
+ if (invalidArtifact)
202
+ return invalidArtifact;
203
+ if (app.compatibilityDate !== undefined && !isCalendarDate(app.compatibilityDate)) {
204
+ return `${at} compatibilityDate must be a date written YYYY-MM-DD`;
205
+ }
206
+ for (const flag of app.compatibilityFlags ?? []) {
207
+ if (!WORKER_APP_COMPATIBILITY_FLAGS.has(flag)) {
208
+ return `${at} compatibilityFlags '${flag}' is not allowed (allowed: ${[...WORKER_APP_COMPATIBILITY_FLAGS].join(', ')})`;
209
+ }
210
+ }
211
+ if (app.host !== '@' && !LABEL.test(app.host)) {
212
+ return `${at} host must be '@' or a DNS label: an exact host, never a wildcard`;
213
+ }
214
+ if (names?.domain && app.host !== '@' && names.locked.has(`${app.host}.${names.domain}`)) {
215
+ return `${at} host '${app.host}' is a name foundry owns: ${app.host}.${names.domain}`;
216
+ }
217
+ if (names) {
218
+ const domain = hosting.usesDomain ? names.domain : null;
219
+ if (domain && app.host === '@' && names.siteOwnsApex) {
220
+ return `${at} host '@' is the apex, which the org's site serves`;
221
+ }
222
+ if (names.slug !== undefined) {
223
+ const served = workerAppHostnameOf({
224
+ slug: names.slug,
225
+ domain: names.domain,
226
+ usesDomain: hosting.usesDomain,
227
+ }, entry.key, app.host);
228
+ if (names.locked.has(served))
229
+ return `${at} host is a name foundry owns: ${served}`;
230
+ // Without a domain the name is `<slug>-<key>.oikos.host`, the shape of
231
+ // every environment and service host of the org (`-auth`, `-agora`,
232
+ // `-boule`, `-stage`, `-pr-<n>`, ...). `slug.ts` already refuses a slug
233
+ // that ends in one of those suffixes, so the same check refuses the key.
234
+ if (!domain && entry.key !== 'app' && validateSlug(`${names.slug}-${entry.key}`)) {
235
+ return `${at} entry key '${entry.key}' would serve ${served}, a name foundry reserves for the org's own services and environments`;
236
+ }
237
+ }
238
+ }
239
+ for (const other of hosting.entries) {
240
+ if (other !== entry && other.workerApp?.host === app.host) {
241
+ return `${at} host '${app.host}' is already declared by entry '${other.key}'`;
242
+ }
243
+ if (other !== entry && other.hostnames.includes(app.host)) {
244
+ return `${at} host '${app.host}' is already a hostname of entry '${other.key}'`;
245
+ }
246
+ }
247
+ const reserved = new Set();
248
+ const claim = (kind, name) => {
249
+ if (!ENV_NAME.test(name)) {
250
+ return `${at} ${kind} '${name}' must be an env name of capital letters, digits and underscores`;
251
+ }
252
+ if (WORKER_APP_RESERVED_NAMES.test(name) &&
253
+ !(kind === 'secret' && name === WORKER_APP_KEYS_SECRET)) {
254
+ return `${at} ${kind} '${name}' is a name foundry writes itself (AUTH_*, KEYS_*, MCP_*, HYPERDRIVE)`;
255
+ }
256
+ if (reserved.has(name))
257
+ return `${at} ${kind} '${name}' is already used by another binding, secret or var`;
258
+ reserved.add(name);
259
+ return null;
260
+ };
261
+ const invalidDo = validateDurableObjects(at, app, claim);
262
+ if (invalidDo)
263
+ return invalidDo;
264
+ const invalidContainers = validateContainers(at, app);
265
+ if (invalidContainers)
266
+ return invalidContainers;
267
+ let keysSecrets = 0;
268
+ for (const [name, source] of Object.entries(app.secrets ?? {})) {
269
+ if (source?.from === 'keys') {
270
+ return `${at} secret '${name}': Worker apps use keysCustody for the custody Worker; issued API keys are not supported yet`;
271
+ }
272
+ const invalid = claim('secret', name);
273
+ if (invalid)
274
+ return invalid;
275
+ if (source?.from === 'generated')
276
+ continue;
277
+ if (source?.from !== 'keysCustody')
278
+ return `${at} secret '${name}' has an unknown source`;
279
+ keysSecrets++;
280
+ if (name !== WORKER_APP_KEYS_SECRET) {
281
+ return `${at} a keysCustody secret is delivered as ${WORKER_APP_KEYS_SECRET}, so it must be named ${WORKER_APP_KEYS_SECRET}`;
282
+ }
283
+ if (keysSecrets > 1)
284
+ return `${at} declares more than one keysCustody secret`;
285
+ // The shape does not depend on the org id, so any id checks the key.
286
+ if (workerAppHostId('0', entry.key) === null) {
287
+ return `${at} entry key '${entry.key}' cannot make a custody host id: app-<${WORKER_APP_HOST_ID_HASH_LENGTH} hex>-<key> must be lowercase letters, digits and single hyphens, at most ${HOST_ID_MAX_LENGTH} characters, and must not end in -next`;
288
+ }
289
+ }
290
+ if (keysSecrets > 0)
291
+ reserved.add(WORKER_APP_KEYS_URL_VAR);
292
+ for (const [name, value] of Object.entries(app.vars ?? {})) {
293
+ const invalid = claim('var', name);
294
+ if (invalid)
295
+ return invalid;
296
+ if (typeof value !== 'string' || value.length > VAR_MAX_LENGTH) {
297
+ return `${at} var '${name}' must be a string of at most ${VAR_MAX_LENGTH} characters`;
298
+ }
299
+ if (SECRET_NAME.test(name)) {
300
+ return `${at} var '${name}' looks like a secret: declare it under workerApp.secrets, not as a plain var`;
301
+ }
302
+ try {
303
+ if (new URL(value).password !== '') {
304
+ return `${at} var '${name}' carries a password in a URL: vars are plain text`;
305
+ }
306
+ }
307
+ catch {
308
+ // Not a URL: nothing to check.
309
+ }
310
+ }
311
+ const invalidDatabase = validateWorkerAppDatabase(at, app);
312
+ if (invalidDatabase)
313
+ return invalidDatabase;
314
+ if (app.authEnv !== undefined && typeof app.authEnv !== 'boolean') {
315
+ return `${at} authEnv must be true or false`;
316
+ }
317
+ if (app.authEnv === true && !entry.uses?.auth)
318
+ return `${at} authEnv needs uses.auth`;
319
+ // Without authEnv the broker entry is never delivered its key and stays
320
+ // blocked, and an MCP server gets none of its MCP_* vars.
321
+ if (entry.uses?.auth && app.authEnv !== true) {
322
+ return `${at} uses.auth needs authEnv: true: foundry delivers the sign-in env and the broker key only then`;
323
+ }
324
+ return null;
325
+ }
326
+ function validateWorkerAppDatabase(at, app) {
327
+ const database = app.database;
328
+ if (database === undefined)
329
+ return null;
330
+ if (typeof database !== 'object' || database === null || Array.isArray(database)) {
331
+ return `${at} database must be an object: { appendOnly?: string[]; migrationsSha256?: string }`;
332
+ }
333
+ const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'migrationsSha256');
334
+ if (unknown) {
335
+ return `${at} database.${unknown} is not a setting: only appendOnly and migrationsSha256`;
336
+ }
337
+ const sha = database.migrationsSha256;
338
+ if (sha !== undefined && (typeof sha !== 'string' || !SHA256_HEX.test(sha))) {
339
+ return `${at} database.migrationsSha256 must be 64 lowercase hex characters`;
340
+ }
341
+ const appendOnly = database.appendOnly;
342
+ if (appendOnly === undefined)
343
+ return null;
344
+ if (!Array.isArray(appendOnly) || appendOnly.length > WORKER_APP_APPEND_ONLY_MAX) {
345
+ return `${at} database.appendOnly must be a list of at most ${WORKER_APP_APPEND_ONLY_MAX} table names`;
346
+ }
347
+ const seen = new Set();
348
+ for (const table of appendOnly) {
349
+ if (typeof table !== 'string' || !TABLE_NAME.test(table)) {
350
+ return `${at} database.appendOnly names '${String(table).slice(0, 80)}', which is not a table name (letters, digits and '_', optionally schema.table)`;
351
+ }
352
+ if (seen.has(table))
353
+ return `${at} database.appendOnly names '${table}' twice`;
354
+ seen.add(table);
355
+ }
356
+ return null;
357
+ }
358
+ function validateDurableObjects(at, app, claim) {
359
+ const { classes, migrations } = app.durableObjects ?? {
360
+ classes: {},
361
+ migrations: [],
362
+ };
363
+ const declared = new Set();
364
+ for (const [binding, className] of Object.entries(classes ?? {})) {
365
+ const invalid = claim('durable object binding', binding);
366
+ if (invalid)
367
+ return invalid;
368
+ if (typeof className !== 'string' || !CLASS_NAME.test(className)) {
369
+ return `${at} durableObjects.classes '${binding}' must name an exported class`;
370
+ }
371
+ if (declared.has(className)) {
372
+ return `${at} durableObjects.classes names class '${className}' twice`;
373
+ }
374
+ declared.add(className);
375
+ }
376
+ const tags = new Set();
377
+ for (const migration of migrations ?? []) {
378
+ if (typeof migration.tag !== 'string' || !MIGRATION_TAG.test(migration.tag)) {
379
+ return `${at} durableObjects.migrations needs a tag of letters, digits, '.', '_' or '-'`;
380
+ }
381
+ if (tags.has(migration.tag))
382
+ return `${at} durableObjects.migrations tag '${migration.tag}' is used twice`;
383
+ tags.add(migration.tag);
384
+ const touched = [
385
+ ...(migration.newSqliteClasses ?? []),
386
+ ...(migration.deletedClasses ?? []),
387
+ ...(migration.renamedClasses ?? []).flatMap((r) => [r.from, r.to]),
388
+ ];
389
+ if (touched.length === 0)
390
+ return `${at} migration '${migration.tag}' changes no class`;
391
+ if (!touched.every((name) => typeof name === 'string' && CLASS_NAME.test(name))) {
392
+ return `${at} migration '${migration.tag}' names a class that is not an exported class name`;
393
+ }
394
+ }
395
+ const folded = foldMigrationClasses(migrations ?? []);
396
+ if ('error' in folded)
397
+ return `${at} ${folded.error}`;
398
+ for (const name of declared) {
399
+ if (!folded.classes.has(name)) {
400
+ return `${at} durableObjects.classes uses class '${name}', which no migration creates`;
401
+ }
402
+ }
403
+ for (const name of folded.classes) {
404
+ if (!declared.has(name)) {
405
+ return `${at} migrations leave class '${name}' with no durableObjects.classes binding`;
406
+ }
407
+ }
408
+ return null;
409
+ }
410
+ function validateContainers(at, app) {
411
+ const classNames = new Set(Object.values(app.durableObjects?.classes ?? {}));
412
+ const seen = new Set();
413
+ for (const container of app.containers ?? []) {
414
+ if (!classNames.has(container.className)) {
415
+ return `${at} containers className '${container.className}' must be a Durable Object class in durableObjects.classes`;
416
+ }
417
+ if (seen.has(container.className)) {
418
+ return `${at} containers names class '${container.className}' twice`;
419
+ }
420
+ seen.add(container.className);
421
+ if (!container.image || !DIGEST.test(container.image.digest ?? '')) {
422
+ return `${at} containers '${container.className}' image needs a digest (sha256:<64 hex>): a tag can move`;
423
+ }
424
+ if (!IMAGE_REPOSITORY.test(container.image.repository ?? '') ||
425
+ container.image.repository.split('/').some((part) => /^\.+$/.test(part))) {
426
+ return `${at} containers '${container.className}' image repository must be a registry path such as registry.example/name, without a tag or digest`;
427
+ }
428
+ if (!WORKER_APP_INSTANCE_TYPES.has(container.instanceType)) {
429
+ return `${at} containers '${container.className}' instanceType '${container.instanceType}' is not one of ${[...WORKER_APP_INSTANCE_TYPES].join(', ')}`;
430
+ }
431
+ if (!Number.isInteger(container.maxInstances) ||
432
+ container.maxInstances < 1 ||
433
+ container.maxInstances > WORKER_APP_MAX_INSTANCES) {
434
+ return `${at} containers '${container.className}' maxInstances must be a whole number from 1 to ${WORKER_APP_MAX_INSTANCES}`;
435
+ }
436
+ }
437
+ return null;
438
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.42.1",
3
+ "version": "0.44.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",
@@ -8,5 +8,6 @@
8
8
  "hc": "sha256:aa08a61b0dcfd83f1332eab195d5cec1b595ae076eb8388cadf8a3d597114f9b",
9
9
  "alpine": "sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1",
10
10
  "busybox": "sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092",
11
- "postgres": "sha256:786dab398303b8ce7cb76b407bb21ef2e4dfbbbd4c6abcf3d29b3130467ffdbc"
11
+ "postgres": "sha256:786dab398303b8ce7cb76b407bb21ef2e4dfbbbd4c6abcf3d29b3130467ffdbc",
12
+ "traefik": "sha256:575fa15b135078fe5e50aa847987d96dbddd7b093c172429618404df73f3fa7c"
12
13
  }
package/versions.json CHANGED
@@ -8,5 +8,6 @@
8
8
  "hc": "v4.4",
9
9
  "alpine": "3.22.1",
10
10
  "busybox": "1.37.0-musl",
11
- "postgres": "16.14-alpine3.22"
11
+ "postgres": "16.14-alpine3.22",
12
+ "traefik": "v3.7.14"
12
13
  }