@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
@@ -25,6 +25,8 @@ export function releaseKeys(entry) {
25
25
  stageCopyHandled: `${p}.stagecopyhandled`,
26
26
  /** The id of a request whose copy failed once; a second failure consumes it. */
27
27
  stageCopyRetry: `${p}.stagecopyretry`,
28
+ /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
29
+ refused: `${p}.refused`,
28
30
  };
29
31
  }
30
32
  export function formatWeights(w) {
@@ -46,6 +48,7 @@ const storedWindow = z.discriminatedUnion('kind', [
46
48
  z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
47
49
  z.object({ kind: z.literal('cap'), since: z.string() }),
48
50
  z.object({ kind: z.literal('staff'), since: z.string() }),
51
+ z.object({ kind: z.literal('paused'), percent: z.number(), since: z.string() }),
49
52
  ]);
50
53
  const releaseField = z
51
54
  .object({
@@ -63,6 +66,28 @@ const signalSchema = z.object({
63
66
  baselineRate: z.number().nullable(),
64
67
  verdict: z.enum(['pass', 'fail', 'hard_fail', 'inconclusive']),
65
68
  });
69
+ export const RELEASE_EVENT_KINDS = [
70
+ 'promote',
71
+ 'step',
72
+ 'check',
73
+ 'pause',
74
+ 'resume',
75
+ 'step_now',
76
+ 'approve',
77
+ 'open',
78
+ 'rollback',
79
+ 'passed',
80
+ 'hard_fail',
81
+ ];
82
+ /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
83
+ export const releaseEventPayload = z.object({
84
+ percent: z.number().optional(),
85
+ actor: z.string().optional(),
86
+ reason: z.string().optional(),
87
+ release: z.object({ from: z.string(), to: z.string() }).optional(),
88
+ signal: signalSchema.optional(),
89
+ });
90
+ export const releaseEventKind = z.enum(RELEASE_EVENT_KINDS);
66
91
  function parseJson(value) {
67
92
  if (!value)
68
93
  return undefined;
@@ -122,6 +147,26 @@ export function parseStageCopy(value) {
122
147
  }
123
148
  return undefined;
124
149
  }
150
+ const REFUSED_MAX_LINES = 20;
151
+ const REFUSED_MAX_LINE = 300;
152
+ const refusedSchema = z.object({ at: z.string(), lines: z.array(z.string()) });
153
+ /**
154
+ * The value of `bg.<id>.refused`: the run time and its refused lines, then its blocked lines. Capped at 20 lines
155
+ * of 300 characters so a bad run cannot grow the row. Callers write it only when `lines` is not empty.
156
+ */
157
+ export function formatRefusals(at, lines) {
158
+ return JSON.stringify({
159
+ at,
160
+ lines: lines.slice(0, REFUSED_MAX_LINES).map((l) => l.slice(0, REFUSED_MAX_LINE)),
161
+ });
162
+ }
163
+ /** Reads `bg.<id>.refused`; a damaged value reads as absent. */
164
+ export function parseRefusals(value) {
165
+ const parsed = refusedSchema.safeParse(parseJson(value));
166
+ if (!parsed.success || Number.isNaN(Date.parse(parsed.data.at)))
167
+ return undefined;
168
+ return parsed.data;
169
+ }
125
170
  export function readSignal(ids, entryKey) {
126
171
  const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
127
172
  return parsed.success ? parsed.data : null;
@@ -133,6 +178,7 @@ export async function readReleaseState(store, input) {
133
178
  if (!r)
134
179
  return null;
135
180
  const stageCopy = parseStageCopy(ids[releaseKeys(input.entryKey).stageCopy]);
181
+ const refused = parseRefusals(ids[releaseKeys(input.entryKey).refused]);
136
182
  return {
137
183
  entryKey: input.entryKey,
138
184
  live: r.live,
@@ -144,6 +190,7 @@ export async function readReleaseState(store, input) {
144
190
  held: r.held,
145
191
  config: resolveReleaseConfig(input.entry),
146
192
  ...(stageCopy ? { stageCopy } : {}),
193
+ ...(refused ? { refused } : {}),
147
194
  ...stageDeployOf(ids[releaseKeys(input.entryKey).stageDeploy]),
148
195
  };
149
196
  }
@@ -163,6 +210,9 @@ const actionSchema = z.discriminatedUnion('kind', [
163
210
  z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
164
211
  z.object({ kind: z.literal('approve_step') }),
165
212
  z.object({ kind: z.literal('open') }),
213
+ z.object({ kind: z.literal('pause') }),
214
+ z.object({ kind: z.literal('resume') }),
215
+ z.object({ kind: z.literal('step_now') }),
166
216
  z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
167
217
  z.object({ kind: z.literal('copy_prod_to_stage') }),
168
218
  z.object({ kind: z.literal('undo_stage_copy') }),
@@ -173,3 +223,22 @@ export async function requestReleaseAction(store, input) {
173
223
  await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
174
224
  }
175
225
  export { actionSchema as releaseActionSchema };
226
+ export const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
227
+ export const RELEASE_HISTORY_MAX_LIMIT = 1000;
228
+ /** C1: the history of one entry (a release's steps, checks, actions and outcome), newest first. */
229
+ export async function readReleaseHistory(store, q) {
230
+ releaseKeys(q.entryKey);
231
+ if (q.since !== undefined && Number.isNaN(Date.parse(q.since))) {
232
+ throw new Error('invalid release history start time');
233
+ }
234
+ const wanted = q.limit !== undefined && Number.isFinite(q.limit)
235
+ ? Math.floor(q.limit)
236
+ : RELEASE_HISTORY_DEFAULT_LIMIT;
237
+ const limit = Math.min(Math.max(wanted, 1), RELEASE_HISTORY_MAX_LIMIT);
238
+ return store.readEvents({
239
+ orgId: q.orgId,
240
+ entryKey: q.entryKey,
241
+ ...(q.since !== undefined ? { since: q.since } : {}),
242
+ limit,
243
+ });
244
+ }
@@ -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;
@@ -59,6 +65,14 @@ export interface ReleaseState {
59
65
  outcome: 'ok' | 'failed';
60
66
  detail: string;
61
67
  };
68
+ /**
69
+ * The latest run that refused or blocked something (`bg.<id>.refused`): its time and its refused lines then its
70
+ * blocked lines, as decide's fixed texts (at most 20 lines of 300 characters). A run with none leaves it as is.
71
+ */
72
+ refused?: {
73
+ at: string;
74
+ lines: string[];
75
+ };
62
76
  }
63
77
  /** C3: what the last `copy_prod_to_stage` or `undo_stage_copy` request did. `detail` is fixed text or an id. */
64
78
  export interface StageCopyState {
@@ -80,6 +94,12 @@ export type ReleaseAction = {
80
94
  kind: 'approve_step';
81
95
  } | {
82
96
  kind: 'open';
97
+ } | {
98
+ kind: 'pause';
99
+ } | {
100
+ kind: 'resume';
101
+ } | {
102
+ kind: 'step_now';
83
103
  } | {
84
104
  kind: 'deploy_stage';
85
105
  ref: string;
@@ -88,6 +108,38 @@ export type ReleaseAction = {
88
108
  } | {
89
109
  kind: 'undo_stage_copy';
90
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
+ }
91
143
  /** The requests that run a stage copy. They are handled by the tick's second phase, never by an apply. */
92
144
  export declare const isStageCopyAction: (action: ReleaseAction) => boolean;
93
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.64.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",