@wtfalch/foundry 0.65.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 +43 -6
  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 +21 -8
  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 +8 -0
  46. package/dist/release/series.d.ts +58 -0
  47. package/dist/release/series.js +65 -0
  48. package/dist/release/state.d.ts +58 -1
  49. package/dist/release/state.js +45 -0
  50. package/dist/release/types.d.ts +44 -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
@@ -27,6 +27,12 @@ export type WindowState = {
27
27
  | {
28
28
  kind: 'staff';
29
29
  since: string;
30
+ }
31
+ /** A person held the step with `pause`. `since` is when the step began; `resume` returns to `step` with it. */
32
+ | {
33
+ kind: 'paused';
34
+ percent: number;
35
+ since: string;
30
36
  };
31
37
  export interface ReleaseSignal {
32
38
  requests: number;
@@ -88,6 +94,12 @@ export type ReleaseAction = {
88
94
  kind: 'approve_step';
89
95
  } | {
90
96
  kind: 'open';
97
+ } | {
98
+ kind: 'pause';
99
+ } | {
100
+ kind: 'resume';
101
+ } | {
102
+ kind: 'step_now';
91
103
  } | {
92
104
  kind: 'deploy_stage';
93
105
  ref: string;
@@ -96,6 +108,38 @@ export type ReleaseAction = {
96
108
  } | {
97
109
  kind: 'undo_stage_copy';
98
110
  };
111
+ export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail';
112
+ /** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
113
+ export interface ReleaseEvent {
114
+ at: string;
115
+ kind: ReleaseEventKind;
116
+ percent?: number;
117
+ /** Who asked, when a person did. */
118
+ actor?: string;
119
+ reason?: string;
120
+ release?: {
121
+ from: string;
122
+ to: string;
123
+ };
124
+ signal?: ReleaseSignal;
125
+ }
126
+ /** C2: what reporting counts for one color in one bucket. */
127
+ export interface ReleaseSeriesColor {
128
+ requests: number;
129
+ errors: number;
130
+ slow: number;
131
+ durationMsSum: number;
132
+ signIns: number;
133
+ signInFailures: number;
134
+ }
135
+ /** C2: reporting's `GET /api/reporting/release-series` body. */
136
+ export interface ReleaseSeries {
137
+ bucketMinutes: number;
138
+ buckets: Array<{
139
+ start: string;
140
+ colors: Record<Color, ReleaseSeriesColor>;
141
+ }>;
142
+ }
99
143
  /** The requests that run a stage copy. They are handled by the tick's second phase, never by an apply. */
100
144
  export declare const isStageCopyAction: (action: ReleaseAction) => boolean;
101
145
  export declare const COLORS: readonly Color[];
@@ -1,13 +1,13 @@
1
- import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
1
+ import { and, asc, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, normalizeHostingRepos, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
3
3
  import { hostingNames } from '../plan/locked-hostnames.js';
4
4
  import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
5
5
  import { validatePlatform } from '../platform.js';
6
- import { releaseActionSchema } from '../release/state.js';
6
+ import { releaseActionSchema, releaseEventKind, releaseEventPayload, } from '../release/state.js';
7
7
  import { RequestedChangeError, classifyChange } from '../requested-changes.js';
8
8
  import { validateSlug } from '../slug.js';
9
9
  import { StaleLeaseError, } from '../store.js';
10
- import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
10
+ import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseEvents, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
11
11
  const UNIQUE_VIOLATION = '23505';
12
12
  /**
13
13
  * #441: runs `write`, and with a `fence` only if its token is still the
@@ -1100,5 +1100,31 @@ export function createPostgresReleaseStore(db) {
1100
1100
  .set({ consumedAt: sql `now()` })
1101
1101
  .where(and(inArray(foundryReleaseRequests.id, [...ids]), isNull(foundryReleaseRequests.consumedAt)));
1102
1102
  },
1103
+ async appendEvents({ orgId, entryKey, events }) {
1104
+ if (events.length === 0)
1105
+ return;
1106
+ await db.insert(foundryReleaseEvents).values(events.map(({ at, kind, ...payload }) => ({
1107
+ orgId,
1108
+ entryKey,
1109
+ at: new Date(at),
1110
+ kind,
1111
+ payload,
1112
+ })));
1113
+ },
1114
+ async readEvents({ orgId, entryKey, since, limit }) {
1115
+ const rows = await db
1116
+ .select()
1117
+ .from(foundryReleaseEvents)
1118
+ .where(and(eq(foundryReleaseEvents.orgId, orgId), eq(foundryReleaseEvents.entryKey, entryKey), ...(since ? [gte(foundryReleaseEvents.at, new Date(since))] : [])))
1119
+ .orderBy(desc(foundryReleaseEvents.at), desc(foundryReleaseEvents.id))
1120
+ .limit(limit);
1121
+ return rows.flatMap((row) => {
1122
+ const kind = releaseEventKind.safeParse(row.kind);
1123
+ const payload = releaseEventPayload.safeParse(row.payload);
1124
+ return kind.success && payload.success
1125
+ ? [{ at: row.at.toISOString(), kind: kind.data, ...payload.data }]
1126
+ : [];
1127
+ });
1128
+ },
1103
1129
  };
1104
1130
  }
@@ -2854,6 +2854,118 @@ export declare const foundryReleaseRequests: import("drizzle-orm/pg-core").PgTab
2854
2854
  };
2855
2855
  dialect: "pg";
2856
2856
  }>;
2857
+ /** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
2858
+ export declare const foundryReleaseEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
2859
+ name: "foundry_release_events";
2860
+ schema: undefined;
2861
+ columns: {
2862
+ id: import("drizzle-orm/pg-core").PgColumn<{
2863
+ name: "id";
2864
+ tableName: "foundry_release_events";
2865
+ dataType: "number";
2866
+ columnType: "PgBigSerial53";
2867
+ data: number;
2868
+ driverParam: number;
2869
+ notNull: true;
2870
+ hasDefault: true;
2871
+ isPrimaryKey: true;
2872
+ isAutoincrement: false;
2873
+ hasRuntimeDefault: false;
2874
+ enumValues: undefined;
2875
+ baseColumn: never;
2876
+ identity: undefined;
2877
+ generated: undefined;
2878
+ }, {}, {}>;
2879
+ orgId: import("drizzle-orm/pg-core").PgColumn<{
2880
+ name: "org_id";
2881
+ tableName: "foundry_release_events";
2882
+ dataType: "string";
2883
+ columnType: "PgText";
2884
+ data: string;
2885
+ driverParam: string;
2886
+ notNull: true;
2887
+ hasDefault: false;
2888
+ isPrimaryKey: false;
2889
+ isAutoincrement: false;
2890
+ hasRuntimeDefault: false;
2891
+ enumValues: [string, ...string[]];
2892
+ baseColumn: never;
2893
+ identity: undefined;
2894
+ generated: undefined;
2895
+ }, {}, {}>;
2896
+ entryKey: import("drizzle-orm/pg-core").PgColumn<{
2897
+ name: "entry_key";
2898
+ tableName: "foundry_release_events";
2899
+ dataType: "string";
2900
+ columnType: "PgText";
2901
+ data: string;
2902
+ driverParam: string;
2903
+ notNull: true;
2904
+ hasDefault: false;
2905
+ isPrimaryKey: false;
2906
+ isAutoincrement: false;
2907
+ hasRuntimeDefault: false;
2908
+ enumValues: [string, ...string[]];
2909
+ baseColumn: never;
2910
+ identity: undefined;
2911
+ generated: undefined;
2912
+ }, {}, {}>;
2913
+ at: import("drizzle-orm/pg-core").PgColumn<{
2914
+ name: "at";
2915
+ tableName: "foundry_release_events";
2916
+ dataType: "date";
2917
+ columnType: "PgTimestamp";
2918
+ data: Date;
2919
+ driverParam: string;
2920
+ notNull: true;
2921
+ hasDefault: false;
2922
+ isPrimaryKey: false;
2923
+ isAutoincrement: false;
2924
+ hasRuntimeDefault: false;
2925
+ enumValues: undefined;
2926
+ baseColumn: never;
2927
+ identity: undefined;
2928
+ generated: undefined;
2929
+ }, {}, {}>;
2930
+ kind: import("drizzle-orm/pg-core").PgColumn<{
2931
+ name: "kind";
2932
+ tableName: "foundry_release_events";
2933
+ dataType: "string";
2934
+ columnType: "PgText";
2935
+ data: string;
2936
+ driverParam: string;
2937
+ notNull: true;
2938
+ hasDefault: false;
2939
+ isPrimaryKey: false;
2940
+ isAutoincrement: false;
2941
+ hasRuntimeDefault: false;
2942
+ enumValues: [string, ...string[]];
2943
+ baseColumn: never;
2944
+ identity: undefined;
2945
+ generated: undefined;
2946
+ }, {}, {}>;
2947
+ payload: import("drizzle-orm/pg-core").PgColumn<{
2948
+ name: "payload";
2949
+ tableName: "foundry_release_events";
2950
+ dataType: "json";
2951
+ columnType: "PgJsonb";
2952
+ data: Record<string, unknown>;
2953
+ driverParam: unknown;
2954
+ notNull: true;
2955
+ hasDefault: true;
2956
+ isPrimaryKey: false;
2957
+ isAutoincrement: false;
2958
+ hasRuntimeDefault: false;
2959
+ enumValues: undefined;
2960
+ baseColumn: never;
2961
+ identity: undefined;
2962
+ generated: undefined;
2963
+ }, {}, {
2964
+ $type: Record<string, unknown>;
2965
+ }>;
2966
+ };
2967
+ dialect: "pg";
2968
+ }>;
2857
2969
  export type DeclaredOrgRow = typeof declaredOrgs.$inferSelect;
2858
2970
  export type NewDeclaredOrgRow = typeof declaredOrgs.$inferInsert;
2859
2971
  export type AppliedStateRow = typeof foundryAppliedState.$inferSelect;
@@ -268,3 +268,12 @@ export const foundryReleaseRequests = pgTable('foundry_release_requests', {
268
268
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
269
269
  consumedAt: timestamp('consumed_at', { withTimezone: true }),
270
270
  });
271
+ /** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
272
+ export const foundryReleaseEvents = pgTable('foundry_release_events', {
273
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
274
+ orgId: text('org_id').notNull(),
275
+ entryKey: text('entry_key').notNull(),
276
+ at: timestamp('at', { withTimezone: true }).notNull(),
277
+ kind: text('kind').notNull(),
278
+ payload: jsonb('payload').notNull().$type().default({}),
279
+ });
@@ -43,12 +43,28 @@ export interface WorkerAppConfig {
43
43
  * `KEYS_CREDENTIAL`, at most one) delivers a keys custody Worker credential
44
44
  * for the app's own host id (`workerAppHostId`), with `KEYS_URL` as a var.
45
45
  * `keys` (an issued API key) is refused: custody does not accept one.
46
+ * `heldSecret` is a value the company supplies (a webhook address, a vendor
47
+ * key): it is read from the held-key store at `heldEnvRef(orgId, entryKey, name)`,
48
+ * the ref a `heldSecret` env of a Coolify entry uses, and set on the Worker
49
+ * only while a value is held. With no value the Worker deploys without the
50
+ * secret and the plan says so.
46
51
  */
47
52
  secrets?: Record<string, {
48
53
  from: 'generated';
49
54
  } | {
50
55
  from: 'keysCustody';
56
+ } | {
57
+ from: 'heldSecret';
51
58
  }>;
59
+ /**
60
+ * Cloudflare cron triggers: at most three standard five-field cron
61
+ * expressions (minute hour day-of-month month day-of-week, UTC), each
62
+ * once. The Worker's `scheduled` handler receives them. Empty or absent
63
+ * clears the triggers.
64
+ */
65
+ triggers?: {
66
+ crons?: string[];
67
+ };
52
68
  /** True delivers the entry's sign-in env as Worker vars. Needs `uses.auth`. */
53
69
  authEnv?: boolean;
54
70
  /**
@@ -64,9 +80,14 @@ export interface WorkerAppConfig {
64
80
  * functions the runtime role may execute, as `name(type, ...)` or
65
81
  * `schema.name(type, ...)`: the security definer functions a migration
66
82
  * revoked from public, such as `@wtfalch/audit`'s ledger functions.
83
+ * `noDelete` names tables the runtime role may select, insert into and
84
+ * update but never delete from or truncate (a looser `appendOnly`: a table
85
+ * in both lists is refused). The app removes rows from them through a
86
+ * `grants` function, as a retention job does.
67
87
  */
68
88
  database?: {
69
89
  appendOnly?: string[];
90
+ noDelete?: string[];
70
91
  grants?: string[];
71
92
  migrationsSha256?: string;
72
93
  };
@@ -80,7 +101,7 @@ export declare const WORKER_APP_COMPATIBILITY_FLAGS: ReadonlySet<string>;
80
101
  export declare const WORKER_APP_INSTANCE_TYPES: ReadonlySet<string>;
81
102
  /** The most instances one container class may scale to. */
82
103
  export declare const WORKER_APP_MAX_INSTANCES = 100;
83
- export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
104
+ export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
84
105
  export declare const WORKER_APP_KEYS_URL_VAR = "KEYS_URL";
85
106
  /** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
86
107
  export declare const entryHasDatabase: (entry: {
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { WORKER_APP_ENV_NAME as ENV_NAME, WORKER_APP_FUNCTION_SIGNATURE as FUNCTION_SIGNATURE, SECRET_LIKE_NAME as SECRET_NAME, WORKER_APP_TABLE_NAME as TABLE_NAME, WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
2
+ import { WORKER_APP_ENV_NAME as ENV_NAME, WORKER_APP_FUNCTION_SIGNATURE as FUNCTION_SIGNATURE, SECRET_LIKE_NAME as SECRET_NAME, WORKER_APP_TABLE_NAME as TABLE_NAME, WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, workerAppCronProblem, } from './entry-rules.js';
3
3
  import { validateSlug } from './slug.js';
4
4
  /** The compatibility flags a Worker app may ask for. */
5
5
  export const WORKER_APP_COMPATIBILITY_FLAGS = new Set(['nodejs_compat']);
@@ -13,7 +13,7 @@ export const WORKER_APP_INSTANCE_TYPES = new Set([
13
13
  ]);
14
14
  /** The most instances one container class may scale to. */
15
15
  export const WORKER_APP_MAX_INSTANCES = 100;
16
- export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
16
+ export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
17
17
  export const WORKER_APP_KEYS_URL_VAR = 'KEYS_URL';
18
18
  /** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
19
19
  export const entryHasDatabase = (entry) => entry.database || Boolean(entry.workerApp?.database);
@@ -52,6 +52,9 @@ const MIGRATION_TAG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
52
52
  const IMAGE_REPOSITORY = /^[a-z0-9]+([._-][a-z0-9]+)*(:[0-9]+)?(\/[a-z0-9]+([._-][a-z0-9]+)*)+$/;
53
53
  const COMPAT_DATE = /^\d{4}-\d{2}-\d{2}$/;
54
54
  const VAR_MAX_LENGTH = 1000;
55
+ /** `heldEnvRef` in `org.ts`: the ref is at most 128 characters and 52 of them are fixed. */
56
+ const HELD_REF_MAX = 128;
57
+ const HELD_REF_FIXED = 52;
55
58
  /**
56
59
  * Checks an artifact reference: an https URL with no userinfo, query or
57
60
  * fragment (those end up verbatim in recorded scope ids) and a lowercase hex
@@ -278,6 +281,16 @@ export function validateEntryWorkerApp(entry, hosting, names) {
278
281
  return invalid;
279
282
  if (source?.from === 'generated')
280
283
  continue;
284
+ if (source?.from === 'heldSecret') {
285
+ if (name === WORKER_APP_KEYS_SECRET) {
286
+ return `${at} secret '${name}' is the custody credential: declare it as keysCustody, not heldSecret`;
287
+ }
288
+ // `heldEnvRef`'s own limit (org.ts restates the ref: this file cannot import it).
289
+ if (HELD_REF_FIXED + entry.key.length + name.length > HELD_REF_MAX) {
290
+ return `${at} secret '${name}' heldSecret: the name and the entry key are too long to hold a value under (at most ${HELD_REF_MAX - HELD_REF_FIXED} characters together)`;
291
+ }
292
+ continue;
293
+ }
281
294
  if (source?.from !== 'keysCustody')
282
295
  return `${at} secret '${name}' has an unknown source`;
283
296
  keysSecrets++;
@@ -312,6 +325,9 @@ export function validateEntryWorkerApp(entry, hosting, names) {
312
325
  // Not a URL: nothing to check.
313
326
  }
314
327
  }
328
+ const invalidTriggers = validateWorkerAppTriggers(at, app);
329
+ if (invalidTriggers)
330
+ return invalidTriggers;
315
331
  const invalidDatabase = validateWorkerAppDatabase(at, app);
316
332
  if (invalidDatabase)
317
333
  return invalidDatabase;
@@ -332,11 +348,11 @@ function validateWorkerAppDatabase(at, app) {
332
348
  if (database === undefined)
333
349
  return null;
334
350
  if (typeof database !== 'object' || database === null || Array.isArray(database)) {
335
- return `${at} database must be an object: { appendOnly?: string[]; grants?: string[]; migrationsSha256?: string }`;
351
+ return `${at} database must be an object: { appendOnly?: string[]; noDelete?: string[]; grants?: string[]; migrationsSha256?: string }`;
336
352
  }
337
- const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'grants' && key !== 'migrationsSha256');
353
+ const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'noDelete' && key !== 'grants' && key !== 'migrationsSha256');
338
354
  if (unknown) {
339
- return `${at} database.${unknown} is not a setting: only appendOnly, grants and migrationsSha256`;
355
+ return `${at} database.${unknown} is not a setting: only appendOnly, noDelete, grants and migrationsSha256`;
340
356
  }
341
357
  const grants = database.grants;
342
358
  if (grants !== undefined) {
@@ -357,21 +373,63 @@ function validateWorkerAppDatabase(at, app) {
357
373
  if (sha !== undefined && (typeof sha !== 'string' || !SHA256_HEX.test(sha))) {
358
374
  return `${at} database.migrationsSha256 must be 64 lowercase hex characters`;
359
375
  }
360
- const appendOnly = database.appendOnly;
361
- if (appendOnly === undefined)
362
- return null;
363
- if (!Array.isArray(appendOnly) || appendOnly.length > WORKER_APP_APPEND_ONLY_MAX) {
364
- return `${at} database.appendOnly must be a list of at most ${WORKER_APP_APPEND_ONLY_MAX} table names`;
376
+ const appendOnly = tableList(at, 'appendOnly', WORKER_APP_APPEND_ONLY_MAX, database);
377
+ if (typeof appendOnly === 'string')
378
+ return appendOnly;
379
+ const noDelete = tableList(at, 'noDelete', WORKER_APP_NO_DELETE_MAX, database);
380
+ if (typeof noDelete === 'string')
381
+ return noDelete;
382
+ const both = noDelete.find((table) => appendOnly.includes(table));
383
+ if (both) {
384
+ return `${at} database.noDelete names '${both}', which database.appendOnly already covers (append-only is stricter): name it in one list`;
385
+ }
386
+ return null;
387
+ }
388
+ /** A table list (`appendOnly` or `noDelete`) of `database`: its names, or the refusal. */
389
+ function tableList(at, key, max, database) {
390
+ const list = database[key];
391
+ if (list === undefined)
392
+ return [];
393
+ if (!Array.isArray(list) || list.length > max) {
394
+ return `${at} database.${key} must be a list of at most ${max} table names`;
365
395
  }
366
396
  const seen = new Set();
367
- for (const table of appendOnly) {
397
+ for (const table of list) {
368
398
  if (typeof table !== 'string' || !TABLE_NAME.test(table)) {
369
- return `${at} database.appendOnly names '${String(table).slice(0, 80)}', which is not a table name (letters, digits and '_', optionally schema.table)`;
399
+ return `${at} database.${key} names '${String(table).slice(0, 80)}', which is not a table name (letters, digits and '_', optionally schema.table)`;
370
400
  }
371
401
  if (seen.has(table))
372
- return `${at} database.appendOnly names '${table}' twice`;
402
+ return `${at} database.${key} names '${table}' twice`;
373
403
  seen.add(table);
374
404
  }
405
+ return [...seen];
406
+ }
407
+ /** `triggers`: only `crons`, at most {@link WORKER_APP_CRONS_MAX}, each a standard five-field cron, each once. */
408
+ function validateWorkerAppTriggers(at, app) {
409
+ const triggers = app.triggers;
410
+ if (triggers === undefined)
411
+ return null;
412
+ if (typeof triggers !== 'object' || triggers === null || Array.isArray(triggers)) {
413
+ return `${at} triggers must be an object: { crons?: string[] }`;
414
+ }
415
+ const unknown = Object.keys(triggers).find((key) => key !== 'crons');
416
+ if (unknown)
417
+ return `${at} triggers.${unknown} is not a trigger: only crons`;
418
+ const crons = triggers.crons;
419
+ if (crons === undefined)
420
+ return null;
421
+ if (!Array.isArray(crons) || crons.length > WORKER_APP_CRONS_MAX) {
422
+ return `${at} triggers.crons must be a list of at most ${WORKER_APP_CRONS_MAX} cron expressions (Cloudflare's limit for one Worker)`;
423
+ }
424
+ const seen = new Set();
425
+ for (const cron of crons) {
426
+ const problem = workerAppCronProblem(cron);
427
+ if (problem)
428
+ return `${at} triggers.crons: ${problem}`;
429
+ if (seen.has(cron))
430
+ return `${at} triggers.crons names '${cron}' twice`;
431
+ seen.add(cron);
432
+ }
375
433
  return null;
376
434
  }
377
435
  function validateDurableObjects(at, app, claim) {
@@ -20,8 +20,10 @@ export interface WorkerAppMigrationRunner<O> {
20
20
  applyMigration(owner: O, migration: DatabaseMigration): Promise<void>;
21
21
  /**
22
22
  * Creates or updates the `runtime.user` role with `runtime.password`, grants it DML on the
23
- * tables, revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table, and grants
24
- * it EXECUTE on each `grants` function.
23
+ * tables, revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table, revokes
24
+ * DELETE and TRUNCATE on each `noDelete` table (`@wtfalch/db`'s `ensureRuntimeRole` takes the
25
+ * same two lists by those names), and grants it EXECUTE on each `grants` function. A runner
26
+ * that drops `noDelete` leaves the role able to delete: pass it on.
25
27
  */
26
28
  ensureRuntimeRole(input: {
27
29
  owner: O;
@@ -30,6 +32,7 @@ export interface WorkerAppMigrationRunner<O> {
30
32
  password: string;
31
33
  };
32
34
  appendOnly: string[];
35
+ noDelete: string[];
33
36
  grants: string[];
34
37
  }): Promise<void>;
35
38
  }
@@ -80,6 +83,7 @@ export declare function pendingPlatformMigrations(platform: readonly PlatformMig
80
83
  */
81
84
  export declare function runWorkerAppMigrations<O>(runner: WorkerAppMigrationRunner<O>, owner: O, text: string | null, want: {
82
85
  appendOnly: readonly string[];
86
+ noDelete?: readonly string[];
83
87
  grants?: readonly string[];
84
88
  migrationsSha256?: string;
85
89
  }, runtime: {
@@ -160,6 +160,7 @@ export async function runWorkerAppMigrations(runner, owner, text, want, runtime,
160
160
  owner,
161
161
  runtime,
162
162
  appendOnly: [...want.appendOnly],
163
+ noDelete: [...(want.noDelete ?? [])],
163
164
  grants: [...(want.grants ?? [])],
164
165
  }));
165
166
  return { applied: done, ran, skipped: platform.skipped };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.65.0",
3
+ "version": "0.66.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",