@wtfalch/foundry 0.64.0 → 0.66.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 (58) hide show
  1. package/dist/apps/cloudflare.d.ts +5 -0
  2. package/dist/apps/deploy.js +33 -1
  3. package/dist/apps/migrations.d.ts +3 -0
  4. package/dist/apps/migrations.js +32 -7
  5. package/dist/entry-rules.d.ts +11 -0
  6. package/dist/entry-rules.js +70 -0
  7. package/dist/index.d.ts +7 -3
  8. package/dist/index.js +4 -2
  9. package/dist/migrations/0069_release_events.sql +16 -0
  10. package/dist/modules/database.js +22 -3
  11. package/dist/native-build.d.ts +16 -12
  12. package/dist/native-build.js +16 -12
  13. package/dist/plan/boule.js +2 -0
  14. package/dist/plan/diff.d.ts +2 -0
  15. package/dist/plan/diff.js +17 -3
  16. package/dist/plan/executor.d.ts +44 -7
  17. package/dist/plan/executor.js +89 -18
  18. package/dist/plan/login-host.d.ts +8 -0
  19. package/dist/plan/login-host.js +8 -0
  20. package/dist/plan/plan-apply.d.ts +9 -0
  21. package/dist/plan/plan-apply.js +31 -1
  22. package/dist/plan/platform-diff.js +5 -0
  23. package/dist/plan/platform-org.d.ts +6 -0
  24. package/dist/plan/platform-providers.d.ts +8 -6
  25. package/dist/plan/platform-providers.js +22 -9
  26. package/dist/plan/release-host.d.ts +30 -1
  27. package/dist/plan/release-host.js +84 -41
  28. package/dist/plan/release-series-reader.d.ts +26 -0
  29. package/dist/plan/release-series-reader.js +40 -0
  30. package/dist/plan/sign-in-archon.d.ts +1 -1
  31. package/dist/plan/sign-in-archon.js +2 -2
  32. package/dist/plan/worker-app-database.d.ts +12 -1
  33. package/dist/plan/worker-app-database.js +13 -0
  34. package/dist/plan/worker-app.d.ts +27 -2
  35. package/dist/plan/worker-app.js +52 -7
  36. package/dist/plan/workspace.d.ts +2 -2
  37. package/dist/plan/workspace.js +3 -3
  38. package/dist/release/decide.d.ts +2 -0
  39. package/dist/release/decide.js +67 -10
  40. package/dist/release/events.d.ts +13 -0
  41. package/dist/release/events.js +67 -0
  42. package/dist/release/health.d.ts +5 -0
  43. package/dist/release/health.js +3 -3
  44. package/dist/release/run.d.ts +7 -2
  45. package/dist/release/run.js +14 -1
  46. package/dist/release/series.d.ts +58 -0
  47. package/dist/release/series.js +65 -0
  48. package/dist/release/state.d.ts +67 -1
  49. package/dist/release/state.js +69 -0
  50. package/dist/release/types.d.ts +52 -0
  51. package/dist/store/postgres.js +29 -3
  52. package/dist/store/schema.d.ts +112 -0
  53. package/dist/store/schema.js +9 -0
  54. package/dist/worker-app-config.d.ts +22 -1
  55. package/dist/worker-app-config.js +71 -13
  56. package/dist/worker-app-migrations.d.ts +6 -2
  57. package/dist/worker-app-migrations.js +1 -0
  58. package/package.json +1 -1
@@ -51,6 +51,11 @@ export interface AppsCloudflare {
51
51
  }[]>;
52
52
  putWorkerSecret(name: string, secretName: string, text: string): Promise<void>;
53
53
  deleteWorkerSecret(name: string, secretName: string): Promise<void>;
54
+ /**
55
+ * Sets the script's cron triggers to exactly `crons`; an empty list clears them. Optional until
56
+ * providers' client has the call: a deploy that declares crons stops, in words, without it.
57
+ */
58
+ setWorkerSchedules?(name: string, crons: string[]): Promise<void>;
54
59
  /** The account's `<name>.workers.dev` name, or `undefined` when it has none. */
55
60
  getWorkersSubdomain(): Promise<string | undefined>;
56
61
  setWorkerScriptSubdomain(name: string, enabled: boolean): Promise<void>;
@@ -401,6 +401,21 @@ async function pruneSecrets(ctx, script, secrets) {
401
401
  ctx.steps.push(`Removed the old secret ${name}`);
402
402
  }
403
403
  }
404
+ /**
405
+ * A live copy's cron triggers are made to match the declaration: set to the list, or cleared when it
406
+ * is empty. A workspace copy runs no scheduled jobs. A client without the call was refused earlier
407
+ * when there was a list to set.
408
+ */
409
+ async function syncSchedules(ctx, script, crons, copy) {
410
+ if (copy !== 'live')
411
+ return;
412
+ const set = ctx.cf.setWorkerSchedules;
413
+ if (!set)
414
+ return;
415
+ await ctx.call('upload', 'set the app’s scheduled jobs', () => set.call(ctx.cf, script, [...crons]));
416
+ if (crons.length > 0)
417
+ ctx.steps.push(`Set ${crons.length} scheduled ${crons.length === 1 ? 'job' : 'jobs'}`);
418
+ }
404
419
  const containerAppName = (script, className) => `${script}-${className}`.toLowerCase();
405
420
  async function waitForRollout(ctx, appId, rolloutId, className) {
406
421
  const deadline = Date.now() + (ctx.deps.rolloutTimeoutMs ?? ROLLOUT_TIMEOUT_MS);
@@ -538,7 +553,14 @@ function prepare(input) {
538
553
  if (clash)
539
554
  throw new Stop('check', `${clash} is both a setting and a secret. Keep only one.`);
540
555
  const missing = [];
556
+ const unset = [];
541
557
  for (const [name, source] of Object.entries(app.secrets ?? {})) {
558
+ if (source.from === 'heldSecret') {
559
+ // The company's own value: without it the app runs without the secret, it is not a stop.
560
+ if (!input.secrets[name])
561
+ unset.push(name);
562
+ continue;
563
+ }
542
564
  if (!input.secrets[name])
543
565
  missing.push(name);
544
566
  if (source.from === 'keysCustody' &&
@@ -573,7 +595,10 @@ function prepare(input) {
573
595
  script: appScriptName(input.app.key, input.app.copy),
574
596
  shape: workerAppShape(app),
575
597
  vars: Object.entries(vars).sort(([a], [b]) => a.localeCompare(b)),
576
- secrets: { ...input.secrets },
598
+ // An unset held secret (no value, or an empty one) is never set as an empty secret.
599
+ secrets: Object.fromEntries(Object.entries(input.secrets).filter(([name]) => !unset.includes(name))),
600
+ unset: unset.sort(),
601
+ crons: input.app.copy === 'live' ? [...(app.triggers?.crons ?? [])].sort() : [],
577
602
  database,
578
603
  migrationsNote: Boolean(app.database?.migrationsSha256),
579
604
  };
@@ -649,6 +674,9 @@ export async function deployWorkerApp(input, deps) {
649
674
  const parsed = prepared.database;
650
675
  hidden.push(parsed.password);
651
676
  }
677
+ if (prepared.crons.length > 0 && !c.cf.setWorkerSchedules) {
678
+ throw new Stop('check', 'This app runs scheduled jobs, and the connection to your Cloudflare account cannot set them yet. Update Boule, then try again.');
679
+ }
652
680
  const address = await findAddress(c, input.app, script);
653
681
  await checkAddressFree(c, address, script);
654
682
  const module = await fetchBuild(c, prepared, input.github ?? null);
@@ -691,6 +719,10 @@ export async function deployWorkerApp(input, deps) {
691
719
  if (!existing)
692
720
  await putSecrets(c, script, prepared.secrets);
693
721
  await pruneSecrets(c, script, prepared.secrets);
722
+ if (prepared.unset.length > 0) {
723
+ c.steps.push(`No value was given for ${prepared.unset.join(', ')}, so the app runs without ${prepared.unset.length === 1 ? 'it' : 'them'}. Add ${prepared.unset.length === 1 ? 'it' : 'them'} under the app’s settings in Boule and deploy again.`);
724
+ }
725
+ await syncSchedules(c, script, prepared.crons, input.app.copy);
694
726
  if (!prepared.database)
695
727
  await deleteHyperdrive(c, 'database', script);
696
728
  await syncContainers(c, script, shape.containers);
@@ -36,6 +36,8 @@ export interface ApplyWorkerAppMigrationsInput {
36
36
  migrationsSha256?: string | undefined;
37
37
  /** build.json's `database.appendOnly`. */
38
38
  appendOnly?: string[] | undefined;
39
+ /** The entry's `database.noDelete`: tables the app's user may not delete from. */
40
+ noDelete?: string[] | undefined;
39
41
  /** build.json's `database.grants`. */
40
42
  grants?: string[] | undefined;
41
43
  }
@@ -70,6 +72,7 @@ export interface WorkerAppDatabaseRunner extends WorkerAppMigrationRunner<Databa
70
72
  runtimeRolePrivileges(owner: DatabaseOwnerAddress, input: {
71
73
  user: string;
72
74
  appendOnly: string[];
75
+ noDelete: string[];
73
76
  grants: string[];
74
77
  }): Promise<RuntimeRolePrivileges>;
75
78
  /** Closes every connection the runner has open now, without waiting for the work on it. */
@@ -84,11 +84,19 @@ function check(input) {
84
84
  if (!Array.isArray(appendOnly) || appendOnly.length > LIST_MAX) {
85
85
  throw new Stop('check', `At most ${LIST_MAX} append-only tables.`);
86
86
  }
87
- for (const table of appendOnly) {
87
+ const noDelete = input.noDelete ?? [];
88
+ if (!Array.isArray(noDelete) || noDelete.length > LIST_MAX) {
89
+ throw new Stop('check', `At most ${LIST_MAX} tables the app may not delete from.`);
90
+ }
91
+ for (const table of [...appendOnly, ...noDelete]) {
88
92
  if (typeof table !== 'string' || !WORKER_APP_TABLE_NAME.test(table)) {
89
93
  throw new Stop('check', `${String(table).slice(0, 80)} is not a table name (letters, digits and _, optionally schema.table).`);
90
94
  }
91
95
  }
96
+ const both = noDelete.find((table) => appendOnly.includes(table));
97
+ if (both) {
98
+ throw new Stop('check', `${both} is listed as append-only and as no-delete. Append-only already stops deleting; list it once.`);
99
+ }
92
100
  if (!Array.isArray(grants) || grants.length > LIST_MAX) {
93
101
  throw new Stop('check', `At most ${LIST_MAX} granted functions.`);
94
102
  }
@@ -155,6 +163,7 @@ export async function applyWorkerAppMigrations(input, deps = {}) {
155
163
  const ms = deps.timeoutMs ?? CALL_TIMEOUT_MS;
156
164
  const ownerAddress = { url: input.ownerUrl };
157
165
  const appendOnly = input.appendOnly ?? [];
166
+ const noDelete = input.noDelete ?? [];
158
167
  const grants = input.grants ?? [];
159
168
  const call = async (label, task, over) => {
160
169
  const step = label.startsWith('apply migration')
@@ -207,6 +216,7 @@ export async function applyWorkerAppMigrations(input, deps = {}) {
207
216
  }
208
217
  const result = await runWorkerAppMigrations(sql, ownerAddress, input.migrationsJson, {
209
218
  appendOnly,
219
+ noDelete,
210
220
  grants,
211
221
  ...(input.migrationsSha256 ? { migrationsSha256: input.migrationsSha256 } : {}),
212
222
  }, { user: runtime.user, password: runtime.password }, {
@@ -226,8 +236,13 @@ export async function applyWorkerAppMigrations(input, deps = {}) {
226
236
  // The only check left after `check`: an applied migration moved or left the file.
227
237
  throw new Stop('migrations', 'A database change that already ran is no longer in the same place in migrations.json. Changes are only ever added at the end; restore the file’s earlier order in a new release.');
228
238
  });
229
- const held = await call('ensure: check the app’s database user', () => sql.runtimeRolePrivileges(ownerAddress, { user: runtime.user, appendOnly, grants }));
230
- proveRole(held, appendOnly, grants);
239
+ const held = await call('ensure: check the app’s database user', () => sql.runtimeRolePrivileges(ownerAddress, {
240
+ user: runtime.user,
241
+ appendOnly,
242
+ noDelete,
243
+ grants,
244
+ }));
245
+ proveRole(held, appendOnly, noDelete, grants);
231
246
  if (result.ran.length === 0)
232
247
  steps.push('The database already had every change of this build');
233
248
  steps.push(`Set up the app’s database user ${runtime.user}`);
@@ -254,7 +269,7 @@ export async function applyWorkerAppMigrations(input, deps = {}) {
254
269
  const named = (id) => (id && MIGRATION_ID.test(id) ? ` ${id}` : '');
255
270
  const editedMessage = (id) => `The database change${named(id)} was edited after it ran. A change that ran is never edited: add a new one in a new release.`;
256
271
  /** The role as read back must be what build.json asks for; else the `role` step fails, naming what. */
257
- function proveRole(held, appendOnly, grants) {
272
+ function proveRole(held, appendOnly, noDelete, grants) {
258
273
  for (const name of appendOnly) {
259
274
  const table = held.tables.find((t) => t.name === name);
260
275
  if (!table?.exists) {
@@ -264,6 +279,15 @@ function proveRole(held, appendOnly, grants) {
264
279
  throw new Stop('role', `The app’s database user can still change or delete rows of ${name}, which build.json says only grows. Write the name in build.json as the table is named, and remove any grant on it to PUBLIC or to a role that user belongs to; then try again.`);
265
280
  }
266
281
  }
282
+ for (const name of noDelete) {
283
+ const table = held.tables.find((t) => t.name === name);
284
+ if (!table?.exists) {
285
+ throw new Stop('role', `The table ${name} that may not be deleted from is not in the database after its changes ran. Write it in the app's database settings as the table is named, then try again.`);
286
+ }
287
+ if (table.delete || table.truncate) {
288
+ throw new Stop('role', `The app’s database user can still delete rows of ${name}, which is set to keep its rows. Write the name as the table is named, and remove any grant on it to PUBLIC or to a role that user belongs to; then try again.`);
289
+ }
290
+ }
267
291
  for (const signature of grants) {
268
292
  const fn = held.functions.find((f) => f.signature === signature);
269
293
  if (!fn?.exists) {
@@ -375,13 +399,14 @@ export function postgresMigrationRunner() {
375
399
  return row.comment === runtimeRoleMarker(database) ? 'ours' : 'other';
376
400
  });
377
401
  },
378
- async ensureRuntimeRole({ owner, runtime, appendOnly, grants }) {
402
+ async ensureRuntimeRole({ owner, runtime, appendOnly, noDelete, grants }) {
379
403
  const { ensureRuntimeRole } = await import('@wtfalch/db/runtime-role');
380
404
  // Not abortable: `@wtfalch/db` opens its own connection. Every statement of it is idempotent.
381
405
  await ensureRuntimeRole({
382
406
  ownerUrl: owner.url,
383
407
  runtimeUrl: runtimeUrlFor(owner.url, runtime.user, runtime.password),
384
408
  appendOnly,
409
+ noDelete,
385
410
  grants,
386
411
  log: () => { },
387
412
  });
@@ -393,7 +418,7 @@ export function postgresMigrationRunner() {
393
418
  await db.database.query(`comment on role ${quoted.role} is ${quoted.note}`);
394
419
  });
395
420
  },
396
- async runtimeRolePrivileges({ url }, { user, appendOnly, grants }) {
421
+ async runtimeRolePrivileges({ url }, { user, appendOnly, noDelete, grants }) {
397
422
  return withOwner(url, async (owner) => {
398
423
  // A right the role holds itself, through PUBLIC, or through a role it belongs to (a
399
424
  // NOINHERIT member can still SET ROLE to it).
@@ -403,7 +428,7 @@ export function postgresMigrationRunner() {
403
428
  where r.rolname <> $1::name and pg_has_role($1::name, r.oid, 'MEMBER')
404
429
  and has_table_privilege(r.oid, c.oid, '${privilege}')))`;
405
430
  const tables = [];
406
- for (const name of appendOnly) {
431
+ for (const name of [...appendOnly, ...noDelete]) {
407
432
  const [row] = await owner.database.query(`select c.oid is not null as exists,
408
433
  coalesce(${holds('UPDATE')}, false) as update,
409
434
  coalesce(${holds('DELETE')}, false) as delete,
@@ -48,6 +48,17 @@ export declare const WORKER_APP_ENV_NAME: RegExp;
48
48
  /** The most tables `database.appendOnly` may name, and the shape of one name (`table` or `schema.table`). */
49
49
  export declare const WORKER_APP_APPEND_ONLY_MAX = 100;
50
50
  export declare const WORKER_APP_TABLE_NAME: RegExp;
51
+ /** The most tables `database.noDelete` may name; the names follow {@link WORKER_APP_TABLE_NAME}. */
52
+ export declare const WORKER_APP_NO_DELETE_MAX = 100;
51
53
  /** The most functions `database.grants` may name, and the shape of one signature. It is spliced into SQL, so nothing else passes. */
52
54
  export declare const WORKER_APP_GRANTS_MAX = 100;
53
55
  export declare const WORKER_APP_FUNCTION_SIGNATURE: RegExp;
56
+ /** Cloudflare allows at most three cron triggers on one Worker. */
57
+ export declare const WORKER_APP_CRONS_MAX = 3;
58
+ /**
59
+ * `null` when `expression` is a standard five-field cron (minute hour day-of-month month
60
+ * day-of-week) built from `*`, numbers, `a-b` ranges, `/step` and comma lists, with month and
61
+ * weekday names allowed; else a plain sentence that names the field and repeats no more than the
62
+ * first 40 characters of the input. Cloudflare's extensions (`L`, `W`, `#`) are refused.
63
+ */
64
+ export declare function workerAppCronProblem(expression: unknown): string | null;
@@ -66,6 +66,76 @@ export const WORKER_APP_ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
66
66
  /** The most tables `database.appendOnly` may name, and the shape of one name (`table` or `schema.table`). */
67
67
  export const WORKER_APP_APPEND_ONLY_MAX = 100;
68
68
  export const WORKER_APP_TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}(\.[A-Za-z_][A-Za-z0-9_]{0,62})?$/;
69
+ /** The most tables `database.noDelete` may name; the names follow {@link WORKER_APP_TABLE_NAME}. */
70
+ export const WORKER_APP_NO_DELETE_MAX = 100;
69
71
  /** The most functions `database.grants` may name, and the shape of one signature. It is spliced into SQL, so nothing else passes. */
70
72
  export const WORKER_APP_GRANTS_MAX = 100;
71
73
  export const WORKER_APP_FUNCTION_SIGNATURE = /^[A-Za-z_][A-Za-z0-9_]{0,62}(\.[A-Za-z_][A-Za-z0-9_]{0,62})?\(([a-z][a-z0-9_]*( [a-z][a-z0-9_]*)?(, [a-z][a-z0-9_]*( [a-z][a-z0-9_]*)?)*)?\)$/;
74
+ /** Cloudflare allows at most three cron triggers on one Worker. */
75
+ export const WORKER_APP_CRONS_MAX = 3;
76
+ const MONTH_NAMES = [
77
+ 'JAN',
78
+ 'FEB',
79
+ 'MAR',
80
+ 'APR',
81
+ 'MAY',
82
+ 'JUN',
83
+ 'JUL',
84
+ 'AUG',
85
+ 'SEP',
86
+ 'OCT',
87
+ 'NOV',
88
+ 'DEC',
89
+ ];
90
+ const WEEKDAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
91
+ const CRON_FIELDS = [
92
+ { name: 'minute', min: 0, max: 59 },
93
+ { name: 'hour', min: 0, max: 23 },
94
+ { name: 'day of the month', min: 1, max: 31 },
95
+ { name: 'month', min: 1, max: 12, names: MONTH_NAMES, first: 1 },
96
+ { name: 'day of the week', min: 0, max: 7, names: WEEKDAY_NAMES, first: 0 },
97
+ ];
98
+ /**
99
+ * `null` when `expression` is a standard five-field cron (minute hour day-of-month month
100
+ * day-of-week) built from `*`, numbers, `a-b` ranges, `/step` and comma lists, with month and
101
+ * weekday names allowed; else a plain sentence that names the field and repeats no more than the
102
+ * first 40 characters of the input. Cloudflare's extensions (`L`, `W`, `#`) are refused.
103
+ */
104
+ export function workerAppCronProblem(expression) {
105
+ if (typeof expression !== 'string')
106
+ return 'it is not text';
107
+ const shown = JSON.stringify(expression.slice(0, 40));
108
+ const fields = expression.split(' ');
109
+ if (fields.length !== 5 || fields.some((field) => field === '')) {
110
+ return `${shown} must be five fields separated by single spaces: minute hour day-of-month month day-of-week`;
111
+ }
112
+ for (const [index, field] of fields.entries()) {
113
+ const spec = CRON_FIELDS[index];
114
+ const value = (token) => {
115
+ if (/^[0-9]{1,2}$/.test(token)) {
116
+ const n = Number(token);
117
+ return n >= spec.min && n <= spec.max ? n : null;
118
+ }
119
+ const at = spec.names?.indexOf(token.toUpperCase()) ?? -1;
120
+ return at >= 0 ? at + (spec.first ?? 0) : null;
121
+ };
122
+ for (const item of field.split(',')) {
123
+ const [base = '', step, extra] = item.split('/');
124
+ if (extra !== undefined || (step !== undefined && !/^[0-9]{1,2}$/.test(step))) {
125
+ return `${shown} has a step in the ${spec.name} field that is not a whole number`;
126
+ }
127
+ if (step !== undefined && Number(step) < 1) {
128
+ return `${shown} has a step of 0 in the ${spec.name} field`;
129
+ }
130
+ if (base === '*')
131
+ continue;
132
+ const [from, to, more] = base.split('-');
133
+ const a = from === undefined ? null : value(from);
134
+ const b = to === undefined ? a : value(to);
135
+ if (more !== undefined || a === null || b === null || a > b) {
136
+ return `${shown} has a ${spec.name} that is not ${spec.min} to ${spec.max}${spec.names ? ' or a name' : ''}`;
137
+ }
138
+ }
139
+ }
140
+ return null;
141
+ }
package/dist/index.d.ts CHANGED
@@ -22,14 +22,18 @@ export { StaleLeaseError } from './store.js';
22
22
  export type { PlanApply, DestroyProgress } from './apply.js';
23
23
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
24
24
  export { createPostgresAuditLog } from './store/audit.js';
25
- export type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
25
+ export type { Color, ReleaseAction, ReleaseEvent, ReleaseEventKind, ReleaseSeries, ReleaseSeriesColor, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
26
26
  export type { ReleaseStore } from './release/state.js';
27
- export { readReleaseState, requestReleaseAction } from './release/state.js';
27
+ export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
28
+ export type { ReleaseSeriesHost, ReleaseSeriesQuery } from './release/series.js';
29
+ export { readReleaseSeries } from './release/series.js';
30
+ export type { EntrySeriesDeps } from './plan/release-series-reader.js';
31
+ export { readEntryReleaseSeries } from './plan/release-series-reader.js';
28
32
  export type { DeclaredClassRemoval, MigrationCompatibilityInput, MigrationCompatibilityProblem, MigrationCompatibilityResult, } from './release/migration-compatibility.js';
29
33
  export { checkMigrationCompatibility } from './release/migration-compatibility.js';
30
34
  export { createPostgresReleaseStore } from './store/postgres.js';
31
35
  export type { FoundryStoreDb } from './store/postgres.js';
32
- export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
36
+ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseEvents, foundryReleaseRequests, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
33
37
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, MailRoute, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
34
38
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
35
39
  export { AdoptRefusedError } from './plan/index.js';
package/dist/index.js CHANGED
@@ -14,10 +14,12 @@ export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_RE
14
14
  export { StaleLeaseError } from './store.js';
15
15
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
16
16
  export { createPostgresAuditLog } from './store/audit.js';
17
- export { readReleaseState, requestReleaseAction } from './release/state.js';
17
+ export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
18
+ export { readReleaseSeries } from './release/series.js';
19
+ export { readEntryReleaseSeries } from './plan/release-series-reader.js';
18
20
  export { checkMigrationCompatibility } from './release/migration-compatibility.js';
19
21
  export { createPostgresReleaseStore } from './store/postgres.js';
20
- export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
22
+ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseEvents, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
21
23
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
22
24
  export { AdoptRefusedError } from './plan/index.js';
23
25
  export { DumpError, dumpNow } from './plan/index.js';
@@ -0,0 +1,16 @@
1
+ -- @wtfalch/foundry: a release's history, one line per step, check, action and outcome, written by the
2
+ -- release run. Append-only: nothing in the package updates or deletes a row. Additive: a new table, no
3
+ -- existing row touched. Copies 0055_release_requests.sql (no foreign key, because `org_id` may be
4
+ -- `platform`). Mirrors src/store/schema.ts's `foundryReleaseEvents`.
5
+
6
+ create table if not exists foundry_release_events (
7
+ id bigserial primary key,
8
+ org_id text not null,
9
+ entry_key text not null,
10
+ at timestamptz not null,
11
+ kind text not null,
12
+ payload jsonb not null default '{}'::jsonb
13
+ );
14
+
15
+ create index if not exists foundry_release_events_entry_idx
16
+ on foundry_release_events (org_id, entry_key, at desc, id desc);
@@ -1,4 +1,4 @@
1
- import { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_FUNCTION_SIGNATURE, WORKER_APP_GRANTS_MAX, WORKER_APP_TABLE_NAME, } from '../entry-rules.js';
1
+ import { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_FUNCTION_SIGNATURE, WORKER_APP_GRANTS_MAX, WORKER_APP_NO_DELETE_MAX, WORKER_APP_TABLE_NAME, } from '../entry-rules.js';
2
2
  import { clone, list, omit, problem, withWorkerApp } from './entry-parts.js';
3
3
  const ADDRESS_HELP = 'From your Postgres provider. The owner can change tables; the app address is a separate user with less access. Use a different database for the workspace than for live.';
4
4
  const ADDRESS_MAX = 2000;
@@ -112,6 +112,15 @@ export const database = {
112
112
  pattern: WORKER_APP_TABLE_NAME.source,
113
113
  max: WORKER_APP_APPEND_ONLY_MAX,
114
114
  },
115
+ {
116
+ key: 'noDelete',
117
+ label: 'Tables the app cannot delete from',
118
+ help: 'We stop the app from deleting rows in these tables. It can still add, read and change them, and it clears old rows through a database function you allow below. Only for apps that run as a Worker app.',
119
+ kind: 'list',
120
+ advanced: true,
121
+ pattern: WORKER_APP_TABLE_NAME.source,
122
+ max: WORKER_APP_NO_DELETE_MAX,
123
+ },
115
124
  {
116
125
  key: 'grants',
117
126
  label: 'Database functions the app may run',
@@ -132,13 +141,18 @@ export const database = {
132
141
  },
133
142
  check(values, _modules, ctx) {
134
143
  if (ctx.runtime === 'server') {
135
- for (const key of ['appendOnly', 'grants']) {
144
+ for (const key of ['appendOnly', 'noDelete', 'grants']) {
136
145
  if (list(values, key) !== undefined) {
137
- return problem(key, 'Tables that keep every row and database functions can only be set for an app that runs as a Worker app.');
146
+ return problem(key, 'Table protections and database functions can only be set for an app that runs as a Worker app.');
138
147
  }
139
148
  }
140
149
  return null;
141
150
  }
151
+ const kept = list(values, 'appendOnly') ?? [];
152
+ const both = (list(values, 'noDelete') ?? []).find((table) => kept.includes(table));
153
+ if (both) {
154
+ return problem('noDelete', `${both} is already in the tables that keep every row, which also stops deleting. Keep it in one list.`);
155
+ }
142
156
  const workspace = readCopy(values, 'workspace');
143
157
  if (workspace && 'message' in workspace)
144
158
  return workspace;
@@ -161,9 +175,12 @@ export const database = {
161
175
  return omit(app, 'database');
162
176
  const next = {};
163
177
  const appendOnly = list(values, 'appendOnly');
178
+ const noDelete = list(values, 'noDelete');
164
179
  const grants = list(values, 'grants');
165
180
  if (appendOnly)
166
181
  next.appendOnly = appendOnly;
182
+ if (noDelete)
183
+ next.noDelete = noDelete;
167
184
  if (grants)
168
185
  next.grants = grants;
169
186
  const pinned = app.database?.migrationsSha256;
@@ -180,6 +197,8 @@ export const database = {
180
197
  const values = {};
181
198
  if (db.appendOnly?.length)
182
199
  values.appendOnly = [...db.appendOnly];
200
+ if (db.noDelete?.length)
201
+ values.noDelete = [...db.noDelete];
183
202
  if (db.grants?.length)
184
203
  values.grants = [...db.grants];
185
204
  return values;
@@ -3,18 +3,18 @@
3
3
  * builds and signs a company's native app shell (estate
4
4
  * `.plans/2026-10-22-app-shells.md`). Boule now owns the `NativeAppConfig`,
5
5
  * the build runs and the company's store keys (estate#262, foundry#951);
6
- * foundry keeps only two things, both in this file: dispatching the
7
- * workflow, and the key Boule presents when it asks the host (Archon) to
8
- * dispatch it.
6
+ * foundry keeps only the dispatch, in this file.
9
7
  *
10
- * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
11
- * route, not foundry's apply loop. The key itself is minted by
12
- * `plan/executor.ts`'s `coolifyBoule` (`ensureNativeBuildKey`), reusing the
13
- * same `mintIfAbsent` path every other per-company Boule secret goes
14
- * through (foundry#970) -- this file keeps only the env name and the byte
15
- * length, not a second mint function (foundry#957 review note 2 and
16
- * thermo note 1: the earlier `mintNativeBuildKey` was a third, buggy copy
17
- * of that same check).
8
+ * `dispatchNativeBuild` runs inside Archon's own `/api/orgs/{orgId}/native/builds`
9
+ * route, not foundry's apply loop.
10
+ *
11
+ * `NATIVE_BUILD_KEY_ENV` and `NATIVE_BUILD_KEY_BYTES` are deprecated
12
+ * (foundry#987): Archon's route now checks the company's own org credential
13
+ * instead (archon#706, boule#432), so nothing mints, delivers or reads
14
+ * `NATIVE_BUILD_KEY` any more -- `plan/executor.ts`'s `coolifyBoule` takes
15
+ * the env row away from any Boule that still has one from #970/#971. Both
16
+ * constants are kept one release for the two-release removal rule and go in
17
+ * the next.
18
18
  */
19
19
  export declare const NATIVE_BUILD_REPO = "wtfalch/shell";
20
20
  export declare const NATIVE_BUILD_WORKFLOW_FILE = "build-app.yml";
@@ -45,6 +45,10 @@ export declare function dispatchNativeBuild(deps: NativeBuildDispatchDeps, run:
45
45
  runId: string;
46
46
  origin: string;
47
47
  }): Promise<void>;
48
- /** The env name Boule presents this key under, to Archon's native-build route. */
48
+ /**
49
+ * @deprecated foundry#987: Archon's native-build route checks the company's own org credential
50
+ * now, not this key. Nothing mints, delivers or reads it any more. Removed in the next release.
51
+ */
49
52
  export declare const NATIVE_BUILD_KEY_ENV = "NATIVE_BUILD_KEY";
53
+ /** @deprecated foundry#987: goes with {@link NATIVE_BUILD_KEY_ENV}. */
50
54
  export declare const NATIVE_BUILD_KEY_BYTES = 32;
@@ -3,18 +3,18 @@
3
3
  * builds and signs a company's native app shell (estate
4
4
  * `.plans/2026-10-22-app-shells.md`). Boule now owns the `NativeAppConfig`,
5
5
  * the build runs and the company's store keys (estate#262, foundry#951);
6
- * foundry keeps only two things, both in this file: dispatching the
7
- * workflow, and the key Boule presents when it asks the host (Archon) to
8
- * dispatch it.
6
+ * foundry keeps only the dispatch, in this file.
9
7
  *
10
- * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
11
- * route, not foundry's apply loop. The key itself is minted by
12
- * `plan/executor.ts`'s `coolifyBoule` (`ensureNativeBuildKey`), reusing the
13
- * same `mintIfAbsent` path every other per-company Boule secret goes
14
- * through (foundry#970) -- this file keeps only the env name and the byte
15
- * length, not a second mint function (foundry#957 review note 2 and
16
- * thermo note 1: the earlier `mintNativeBuildKey` was a third, buggy copy
17
- * of that same check).
8
+ * `dispatchNativeBuild` runs inside Archon's own `/api/orgs/{orgId}/native/builds`
9
+ * route, not foundry's apply loop.
10
+ *
11
+ * `NATIVE_BUILD_KEY_ENV` and `NATIVE_BUILD_KEY_BYTES` are deprecated
12
+ * (foundry#987): Archon's route now checks the company's own org credential
13
+ * instead (archon#706, boule#432), so nothing mints, delivers or reads
14
+ * `NATIVE_BUILD_KEY` any more -- `plan/executor.ts`'s `coolifyBoule` takes
15
+ * the env row away from any Boule that still has one from #970/#971. Both
16
+ * constants are kept one release for the two-release removal rule and go in
17
+ * the next.
18
18
  */
19
19
  export const NATIVE_BUILD_REPO = 'wtfalch/shell';
20
20
  export const NATIVE_BUILD_WORKFLOW_FILE = 'build-app.yml';
@@ -67,6 +67,10 @@ export async function dispatchNativeBuild(deps, run) {
67
67
  if (!response.ok)
68
68
  throw new NativeBuildDispatchError(response.status);
69
69
  }
70
- /** The env name Boule presents this key under, to Archon's native-build route. */
70
+ /**
71
+ * @deprecated foundry#987: Archon's native-build route checks the company's own org credential
72
+ * now, not this key. Nothing mints, delivers or reads it any more. Removed in the next release.
73
+ */
71
74
  export const NATIVE_BUILD_KEY_ENV = 'NATIVE_BUILD_KEY';
75
+ /** @deprecated foundry#987: goes with {@link NATIVE_BUILD_KEY_ENV}. */
72
76
  export const NATIVE_BUILD_KEY_BYTES = 32;
@@ -177,6 +177,8 @@ export const BOULE_MANAGED_ENV_KEYS = [
177
177
  BOULE_PEER_PASSWORD_ENV,
178
178
  BOULE_BRIDGE_SECRET_ENV,
179
179
  BOULE_OWNER_SEED_SECRET_ENV,
180
+ // Deprecated (foundry#987): kept in the list so the live reader still sees it, which is how
181
+ // `planBoule` finds a stale one to take away. Goes with `NATIVE_BUILD_KEY_ENV` next release.
180
182
  NATIVE_BUILD_KEY_ENV,
181
183
  BOULE_ARCHON_ORG_ID_ENV,
182
184
  BOULE_ARCHON_CREDENTIAL_ENV,
@@ -85,6 +85,8 @@ export interface PlanOrgOptions {
85
85
  keysRegistrarUrl?: string;
86
86
  /** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
87
87
  workerAppDatabase?: boolean;
88
+ /** True when that port has `deletableTables`; a `workerApp.database.noDelete` is blocked without it. */
89
+ workerAppNoDeleteCheck?: boolean;
88
90
  /** ADR 0002: the platform's Cloudflare account id; a container image must live in its registry. */
89
91
  workerAppRegistryAccountId?: string;
90
92
  /** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
package/dist/plan/diff.js CHANGED
@@ -30,7 +30,7 @@ import { planKeysRegistrar } from './keys-registrar.js';
30
30
  import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
31
31
  import { APP_GONE_KIND } from './live.js';
32
32
  import { lockedHostnames } from './locked-hostnames.js';
33
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
33
+ import { LOGIN_BASE_URI_SWEEP_SCOPE_KEY, LOGIN_BASE_URI_SWEEP_VERSION, LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
34
34
  import { planMachineBypasses } from './machine-paths.js';
35
35
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
36
36
  import { mailStoreSwitchChange } from './mail-store.js';
@@ -835,6 +835,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
835
835
  : {}),
836
836
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
837
837
  issuer: authIssuer,
838
+ ...(platformReadiness.workerAppSecrets
839
+ ? { heldSecrets: platformReadiness.workerAppSecrets }
840
+ : {}),
838
841
  ...(options.workerAppDatabase
839
842
  ? {
840
843
  database: {
@@ -844,6 +847,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
844
847
  tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
845
848
  plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
846
849
  accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
850
+ noDeleteCheck: options.workerAppNoDeleteCheck === true,
847
851
  },
848
852
  }
849
853
  : {}),
@@ -1627,7 +1631,14 @@ function planOwnLoginHost(org, recordedIds, gates) {
1627
1631
  const customersOwed = Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]) &&
1628
1632
  ownCustomersAppState(org, recordedIds) !==
1629
1633
  (recordedIds[OWN_CUSTOMERS_BASE_URI_SCOPE_KEY] ?? '');
1630
- if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] && !mapOwed && !customersOwed) {
1634
+ // With in-app sign-in applied `loginV2.required` is off: every app of the instance owes a base URI until swept.
1635
+ const sweepOwed = Boolean(recordedIds[SIGN_IN_IN_APP_SCOPE_KEY]) &&
1636
+ recordedIds[LOGIN_BASE_URI_SWEEP_SCOPE_KEY] !== LOGIN_BASE_URI_SWEEP_VERSION;
1637
+ if (!inAppSignIn(org) &&
1638
+ !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] &&
1639
+ !mapOwed &&
1640
+ !customersOwed &&
1641
+ !sweepOwed) {
1631
1642
  return none;
1632
1643
  }
1633
1644
  // The company's own sign-in is reported on its own line (`coolify:own-auth`).
@@ -1646,6 +1657,7 @@ function planOwnLoginHost(org, recordedIds, gates) {
1646
1657
  (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1647
1658
  !mapOwed &&
1648
1659
  !customersOwed &&
1660
+ !sweepOwed &&
1649
1661
  pendingRemovalHosts(recordedIds).length === 0) {
1650
1662
  return { step: null, blocked: waiting };
1651
1663
  }
@@ -3388,7 +3400,9 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3388
3400
  drift.push(BOULE_BRIDGE_SECRET_ENV);
3389
3401
  if (!liveEnv[BOULE_OWNER_SEED_SECRET_ENV])
3390
3402
  drift.push(BOULE_OWNER_SEED_SECRET_ENV);
3391
- if (!liveEnv[NATIVE_BUILD_KEY_ENV])
3403
+ // foundry#987: nothing reads this any more (Boule calls Archon's org build route with its
3404
+ // own org credential instead). A live one is drift, so the step removes it rather than minting it.
3405
+ if (liveEnv[NATIVE_BUILD_KEY_ENV])
3392
3406
  drift.push(NATIVE_BUILD_KEY_ENV);
3393
3407
  // Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
3394
3408
  if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {