@wtfalch/foundry 0.65.0 → 0.67.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 (88) 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/catalogue/copy-en.js +5 -0
  6. package/dist/catalogue/integrations.js +5 -0
  7. package/dist/entry-rules.d.ts +11 -0
  8. package/dist/entry-rules.js +70 -0
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.js +5 -2
  11. package/dist/migrations/0069_release_events.sql +16 -0
  12. package/dist/modules/analytics.d.ts +8 -0
  13. package/dist/modules/analytics.js +28 -0
  14. package/dist/modules/database.js +22 -3
  15. package/dist/modules/index.js +2 -0
  16. package/dist/native-build.d.ts +16 -12
  17. package/dist/native-build.js +16 -12
  18. package/dist/org.d.ts +9 -1
  19. package/dist/org.js +4 -1
  20. package/dist/plan/analytics-boule.d.ts +17 -0
  21. package/dist/plan/analytics-boule.js +43 -0
  22. package/dist/plan/analytics.d.ts +58 -0
  23. package/dist/plan/analytics.js +89 -0
  24. package/dist/plan/boule.js +2 -0
  25. package/dist/plan/company-backups.js +2 -0
  26. package/dist/plan/diff.d.ts +4 -0
  27. package/dist/plan/diff.js +103 -3
  28. package/dist/plan/entry-analytics-key.d.ts +35 -0
  29. package/dist/plan/entry-analytics-key.js +134 -0
  30. package/dist/plan/executor.d.ts +79 -6
  31. package/dist/plan/executor.js +379 -19
  32. package/dist/plan/hostnames.js +4 -0
  33. package/dist/plan/index.d.ts +2 -0
  34. package/dist/plan/index.js +1 -0
  35. package/dist/plan/live.d.ts +1 -1
  36. package/dist/plan/locked-hostnames.js +3 -0
  37. package/dist/plan/login-host.d.ts +8 -0
  38. package/dist/plan/login-host.js +8 -0
  39. package/dist/plan/org-names.d.ts +3 -3
  40. package/dist/plan/org-names.js +3 -0
  41. package/dist/plan/own-limits.d.ts +6 -0
  42. package/dist/plan/own-limits.js +1 -0
  43. package/dist/plan/plan-apply.d.ts +12 -0
  44. package/dist/plan/plan-apply.js +40 -1
  45. package/dist/plan/platform-diff.d.ts +17 -1
  46. package/dist/plan/platform-diff.js +40 -2
  47. package/dist/plan/platform-org.d.ts +6 -0
  48. package/dist/plan/platform-providers.d.ts +8 -6
  49. package/dist/plan/platform-providers.js +56 -10
  50. package/dist/plan/providers-readers.js +16 -1
  51. package/dist/plan/release-host.d.ts +30 -1
  52. package/dist/plan/release-host.js +84 -41
  53. package/dist/plan/release-series-reader.d.ts +26 -0
  54. package/dist/plan/release-series-reader.js +40 -0
  55. package/dist/plan/sign-in-archon.d.ts +1 -1
  56. package/dist/plan/sign-in-archon.js +2 -2
  57. package/dist/plan/stage-services.d.ts +3 -1
  58. package/dist/plan/stage-services.js +2 -1
  59. package/dist/plan/uptime-worker.js +53 -3
  60. package/dist/plan/worker-app-database.d.ts +12 -1
  61. package/dist/plan/worker-app-database.js +13 -0
  62. package/dist/plan/worker-app.d.ts +27 -2
  63. package/dist/plan/worker-app.js +52 -7
  64. package/dist/plan/workspace.d.ts +2 -2
  65. package/dist/plan/workspace.js +3 -3
  66. package/dist/release/decide.d.ts +2 -0
  67. package/dist/release/decide.js +67 -10
  68. package/dist/release/events.d.ts +13 -0
  69. package/dist/release/events.js +67 -0
  70. package/dist/release/health.d.ts +5 -0
  71. package/dist/release/health.js +3 -3
  72. package/dist/release/run.d.ts +7 -2
  73. package/dist/release/run.js +8 -0
  74. package/dist/release/series.d.ts +58 -0
  75. package/dist/release/series.js +65 -0
  76. package/dist/release/state.d.ts +58 -1
  77. package/dist/release/state.js +45 -0
  78. package/dist/release/types.d.ts +44 -0
  79. package/dist/slug.d.ts +1 -1
  80. package/dist/slug.js +5 -2
  81. package/dist/store/postgres.js +29 -3
  82. package/dist/store/schema.d.ts +112 -0
  83. package/dist/store/schema.js +9 -0
  84. package/dist/worker-app-config.d.ts +22 -1
  85. package/dist/worker-app-config.js +73 -15
  86. package/dist/worker-app-migrations.d.ts +6 -2
  87. package/dist/worker-app-migrations.js +1 -0
  88. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type Recorded, resolveReleaseConfig } from './decide.js';
3
- import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
3
+ import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
4
4
  /**
5
5
  * The persistence the release runner and Archon share. `getScopeIds` is the
6
6
  * recorded `bg.*` keys of an org's applied state (or of the platform, for the
@@ -26,6 +26,19 @@ export interface ReleaseStore {
26
26
  }[]>;
27
27
  /** Marks requests consumed. The runner calls it only after its decision is persisted. */
28
28
  consumeRequests(ids: readonly number[]): Promise<void>;
29
+ /** Appends history lines (`foundry_release_events`), oldest first. Nothing is ever updated or deleted. */
30
+ appendEvents(input: {
31
+ orgId: string;
32
+ entryKey: string;
33
+ events: readonly ReleaseEvent[];
34
+ }): Promise<void>;
35
+ /** The history of one entry, newest first. `since` is inclusive. Rows that no longer parse are skipped. */
36
+ readEvents(input: {
37
+ orgId: string;
38
+ entryKey: string;
39
+ since?: string;
40
+ limit: number;
41
+ }): Promise<ReleaseEvent[]>;
29
42
  }
30
43
  /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
31
44
  export declare function releaseKeys(entry: string): {
@@ -53,6 +66,41 @@ export declare function releaseKeys(entry: string): {
53
66
  };
54
67
  export declare function formatWeights(w: Record<Color, number>): string;
55
68
  export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
69
+ export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail"];
70
+ /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
71
+ export declare const releaseEventPayload: z.ZodObject<{
72
+ percent: z.ZodOptional<z.ZodNumber>;
73
+ actor: z.ZodOptional<z.ZodString>;
74
+ reason: z.ZodOptional<z.ZodString>;
75
+ release: z.ZodOptional<z.ZodObject<{
76
+ from: z.ZodString;
77
+ to: z.ZodString;
78
+ }, z.core.$strip>>;
79
+ signal: z.ZodOptional<z.ZodObject<{
80
+ requests: z.ZodNumber;
81
+ errors: z.ZodNumber;
82
+ baselineRate: z.ZodNullable<z.ZodNumber>;
83
+ verdict: z.ZodEnum<{
84
+ inconclusive: "inconclusive";
85
+ pass: "pass";
86
+ fail: "fail";
87
+ hard_fail: "hard_fail";
88
+ }>;
89
+ }, z.core.$strip>>;
90
+ }, z.core.$strip>;
91
+ export declare const releaseEventKind: z.ZodEnum<{
92
+ step: "step";
93
+ open: "open";
94
+ check: "check";
95
+ passed: "passed";
96
+ hard_fail: "hard_fail";
97
+ promote: "promote";
98
+ rollback: "rollback";
99
+ pause: "pause";
100
+ resume: "resume";
101
+ step_now: "step_now";
102
+ approve: "approve";
103
+ }>;
56
104
  /** The window JSON is the C2 state plus the active release's `release` field. */
57
105
  export declare function parseWindow(value: string | undefined): {
58
106
  window: WindowState;
@@ -86,3 +134,12 @@ export declare function requestReleaseAction(store: ReleaseStore, input: {
86
134
  actor: string;
87
135
  }): Promise<void>;
88
136
  export { actionSchema as releaseActionSchema };
137
+ export declare const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
138
+ export declare const RELEASE_HISTORY_MAX_LIMIT = 1000;
139
+ /** C1: the history of one entry (a release's steps, checks, actions and outcome), newest first. */
140
+ export declare function readReleaseHistory(store: Pick<ReleaseStore, 'readEvents'>, q: {
141
+ orgId: string;
142
+ entryKey: string;
143
+ since?: string;
144
+ limit?: number;
145
+ }): Promise<ReleaseEvent[]>;
@@ -48,6 +48,7 @@ const storedWindow = z.discriminatedUnion('kind', [
48
48
  z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
49
49
  z.object({ kind: z.literal('cap'), since: z.string() }),
50
50
  z.object({ kind: z.literal('staff'), since: z.string() }),
51
+ z.object({ kind: z.literal('paused'), percent: z.number(), since: z.string() }),
51
52
  ]);
52
53
  const releaseField = z
53
54
  .object({
@@ -65,6 +66,28 @@ const signalSchema = z.object({
65
66
  baselineRate: z.number().nullable(),
66
67
  verdict: z.enum(['pass', 'fail', 'hard_fail', 'inconclusive']),
67
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);
68
91
  function parseJson(value) {
69
92
  if (!value)
70
93
  return undefined;
@@ -187,6 +210,9 @@ const actionSchema = z.discriminatedUnion('kind', [
187
210
  z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
188
211
  z.object({ kind: z.literal('approve_step') }),
189
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') }),
190
216
  z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
191
217
  z.object({ kind: z.literal('copy_prod_to_stage') }),
192
218
  z.object({ kind: z.literal('undo_stage_copy') }),
@@ -197,3 +223,22 @@ export async function requestReleaseAction(store, input) {
197
223
  await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
198
224
  }
199
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;
@@ -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[];
package/dist/slug.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export declare const RESERVED_SLUGS: ReadonlySet<string>;
3
3
  /** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
4
4
  export declare const RESERVED_SLUG_PREFIX = "platform-";
5
- /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
5
+ /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
6
6
  export declare function hasBarredSlugSuffix(label: string): boolean;
7
7
  /**
8
8
  * Why {@link validateSlug} refused a candidate slug, by name so a caller —
package/dist/slug.js CHANGED
@@ -146,6 +146,8 @@ const BOULE_SUFFIX = /-boule$/;
146
146
  * hostname once kosmos is on, not because every org gets one.
147
147
  */
148
148
  const KOSMOS_SUFFIX = /-kosmos$/;
149
+ /** `<something>-analytics` — a level-1 org's own analytics backend hostname (`<slug>-analytics.oikos.host`, foundry#724). Reserved always, so a company that turns analytics on later never finds the name taken. */
150
+ const ANALYTICS_SUFFIX = /-analytics$/;
149
151
  /** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
150
152
  const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
151
153
  /**
@@ -159,7 +161,7 @@ const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
159
161
  * `names-`.
160
162
  */
161
163
  const NAMES_PREFIX = /^names(-|$)/;
162
- /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
164
+ /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
163
165
  export function hasBarredSlugSuffix(label) {
164
166
  return (STAGE_SUFFIX.test(label) ||
165
167
  PR_SUFFIX.test(label) ||
@@ -167,6 +169,7 @@ export function hasBarredSlugSuffix(label) {
167
169
  AGORA_SUFFIX.test(label) ||
168
170
  BOULE_SUFFIX.test(label) ||
169
171
  KOSMOS_SUFFIX.test(label) ||
172
+ ANALYTICS_SUFFIX.test(label) ||
170
173
  OWN_SERVICE_SUFFIX.test(label));
171
174
  }
172
175
  /**
@@ -198,7 +201,7 @@ export function validateSlug(slug) {
198
201
  if (hasBarredSlugSuffix(slug)) {
199
202
  return {
200
203
  reason: 'environment-suffix',
201
- message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
204
+ message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-analytics', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
202
205
  };
203
206
  }
204
207
  return null;
@@ -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
@@ -193,9 +196,9 @@ export function validateEntryWorkerApp(entry, hosting, names) {
193
196
  if (Object.keys(entry.env).length > 0) {
194
197
  return `${at} has no Coolify app, so env does not apply: use workerApp.vars or workerApp.secrets`;
195
198
  }
196
- const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth' && use !== 'audit');
199
+ const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth' && use !== 'audit' && use !== 'analytics');
197
200
  if (unsupported)
198
- return `${at} supports uses.auth and uses.audit only, not uses.${unsupported}`;
201
+ return `${at} supports uses.auth, uses.audit and uses.analytics only, not uses.${unsupported}`;
199
202
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
200
203
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "${WORKER_APP_NAME_SUFFIX}") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
201
204
  }
@@ -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) {