@wtfalch/foundry 0.60.0 → 0.62.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 (236) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +14 -5
  44. package/dist/index.js +7 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/migrations/0065_native_build_runs.sql +32 -0
  49. package/dist/migrations/0066_org_kosmos.sql +10 -0
  50. package/dist/modules/ai.d.ts +3 -0
  51. package/dist/modules/ai.js +64 -0
  52. package/dist/modules/assistants.d.ts +3 -0
  53. package/dist/modules/assistants.js +72 -0
  54. package/dist/modules/audit-ledger.d.ts +29 -0
  55. package/dist/modules/audit-ledger.js +49 -0
  56. package/dist/modules/audit.d.ts +7 -0
  57. package/dist/modules/audit.js +44 -0
  58. package/dist/modules/database.d.ts +23 -0
  59. package/dist/modules/database.js +189 -0
  60. package/dist/modules/email.d.ts +3 -0
  61. package/dist/modules/email.js +18 -0
  62. package/dist/modules/entry-parts.d.ts +23 -0
  63. package/dist/modules/entry-parts.js +63 -0
  64. package/dist/modules/files.d.ts +3 -0
  65. package/dist/modules/files.js +64 -0
  66. package/dist/modules/index.d.ts +74 -0
  67. package/dist/modules/index.js +774 -0
  68. package/dist/modules/jobs.d.ts +3 -0
  69. package/dist/modules/jobs.js +23 -0
  70. package/dist/modules/organisations.d.ts +3 -0
  71. package/dist/modules/organisations.js +20 -0
  72. package/dist/modules/outputs.d.ts +9 -0
  73. package/dist/modules/outputs.js +38 -0
  74. package/dist/modules/sign-in.d.ts +3 -0
  75. package/dist/modules/sign-in.js +197 -0
  76. package/dist/modules/signed-records.d.ts +3 -0
  77. package/dist/modules/signed-records.js +32 -0
  78. package/dist/modules/types.d.ts +194 -0
  79. package/dist/modules/types.js +1 -0
  80. package/dist/modules/workspaces.d.ts +3 -0
  81. package/dist/modules/workspaces.js +75 -0
  82. package/dist/native-build.d.ts +70 -0
  83. package/dist/native-build.js +92 -0
  84. package/dist/org.d.ts +184 -24
  85. package/dist/org.js +444 -49
  86. package/dist/plan/agora.d.ts +1 -1
  87. package/dist/plan/agora.js +4 -0
  88. package/dist/plan/apps-db-route.d.ts +90 -0
  89. package/dist/plan/apps-db-route.js +168 -0
  90. package/dist/plan/apps-zones.d.ts +56 -0
  91. package/dist/plan/apps-zones.js +118 -0
  92. package/dist/plan/archon-blue-green.js +11 -1
  93. package/dist/plan/blue-green-apply.d.ts +37 -1
  94. package/dist/plan/blue-green-apply.js +55 -15
  95. package/dist/plan/blue-green.d.ts +60 -8
  96. package/dist/plan/blue-green.js +115 -10
  97. package/dist/plan/boule.d.ts +23 -1
  98. package/dist/plan/boule.js +35 -2
  99. package/dist/plan/broker-entries.d.ts +28 -3
  100. package/dist/plan/broker-entries.js +74 -3
  101. package/dist/plan/broker-registrar.d.ts +168 -0
  102. package/dist/plan/broker-registrar.js +440 -0
  103. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  104. package/dist/plan/broker-workspaces-database.js +87 -0
  105. package/dist/plan/deploy-health.js +9 -2
  106. package/dist/plan/desktop-callback.js +15 -7
  107. package/dist/plan/diff.d.ts +8 -2
  108. package/dist/plan/diff.js +552 -104
  109. package/dist/plan/edge-access.d.ts +6 -0
  110. package/dist/plan/edge-access.js +1 -0
  111. package/dist/plan/edge-dns.d.ts +6 -0
  112. package/dist/plan/edge-dns.js +9 -7
  113. package/dist/plan/entry-ai-key.d.ts +1 -1
  114. package/dist/plan/entry-ai-key.js +2 -2
  115. package/dist/plan/entry-audit.d.ts +58 -0
  116. package/dist/plan/entry-audit.js +122 -0
  117. package/dist/plan/entry-customer-grants.d.ts +75 -0
  118. package/dist/plan/entry-customer-grants.js +154 -0
  119. package/dist/plan/entry-edge-key.d.ts +26 -1
  120. package/dist/plan/entry-edge-key.js +42 -3
  121. package/dist/plan/entry-jobs.d.ts +64 -0
  122. package/dist/plan/entry-jobs.js +92 -0
  123. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  124. package/dist/plan/entry-repo-key-apply.js +185 -0
  125. package/dist/plan/entry-repo-key.d.ts +92 -0
  126. package/dist/plan/entry-repo-key.js +235 -0
  127. package/dist/plan/entry-workspaces.d.ts +55 -0
  128. package/dist/plan/entry-workspaces.js +90 -0
  129. package/dist/plan/executor.d.ts +231 -19
  130. package/dist/plan/executor.js +1648 -129
  131. package/dist/plan/gates.d.ts +1 -1
  132. package/dist/plan/gates.js +62 -9
  133. package/dist/plan/github-deploy-keys.d.ts +78 -0
  134. package/dist/plan/github-deploy-keys.js +305 -0
  135. package/dist/plan/github-source.d.ts +53 -0
  136. package/dist/plan/github-source.js +140 -0
  137. package/dist/plan/held-key-refs.d.ts +12 -0
  138. package/dist/plan/held-key-refs.js +12 -0
  139. package/dist/plan/hostnames.d.ts +9 -0
  140. package/dist/plan/hostnames.js +28 -1
  141. package/dist/plan/index.d.ts +9 -0
  142. package/dist/plan/index.js +6 -0
  143. package/dist/plan/jobs-scheduler-http.js +3 -0
  144. package/dist/plan/keys-registrar-http.d.ts +32 -0
  145. package/dist/plan/keys-registrar-http.js +105 -0
  146. package/dist/plan/keys-registrar.d.ts +67 -0
  147. package/dist/plan/keys-registrar.js +103 -0
  148. package/dist/plan/kosmos.d.ts +56 -0
  149. package/dist/plan/kosmos.js +84 -0
  150. package/dist/plan/live.d.ts +1 -1
  151. package/dist/plan/locked-hostnames.d.ts +2 -2
  152. package/dist/plan/locked-hostnames.js +7 -4
  153. package/dist/plan/login-host.js +11 -5
  154. package/dist/plan/machine-paths.d.ts +36 -0
  155. package/dist/plan/machine-paths.js +137 -0
  156. package/dist/plan/mcp.d.ts +1 -1
  157. package/dist/plan/mcp.js +49 -17
  158. package/dist/plan/org-apps-admin.d.ts +108 -0
  159. package/dist/plan/org-apps-admin.js +434 -0
  160. package/dist/plan/org-names.d.ts +3 -3
  161. package/dist/plan/org-names.js +15 -2
  162. package/dist/plan/org-postgres-migrate.js +1 -0
  163. package/dist/plan/own-diff.d.ts +23 -0
  164. package/dist/plan/own-diff.js +53 -0
  165. package/dist/plan/own-hostnames.d.ts +2 -0
  166. package/dist/plan/own-hostnames.js +1 -0
  167. package/dist/plan/own-jobs-route.d.ts +47 -0
  168. package/dist/plan/own-jobs-route.js +91 -0
  169. package/dist/plan/own-limits.d.ts +6 -0
  170. package/dist/plan/own-limits.js +1 -0
  171. package/dist/plan/own-services.d.ts +4 -0
  172. package/dist/plan/own-services.js +4 -0
  173. package/dist/plan/plan-apply.d.ts +39 -0
  174. package/dist/plan/plan-apply.js +163 -9
  175. package/dist/plan/platform-diff.d.ts +2 -2
  176. package/dist/plan/platform-diff.js +7 -2
  177. package/dist/plan/platform-org.d.ts +30 -0
  178. package/dist/plan/platform-org.js +12 -1
  179. package/dist/plan/platform-providers.d.ts +2 -0
  180. package/dist/plan/platform-providers.js +13 -2
  181. package/dist/plan/post-logout-uris.d.ts +1 -1
  182. package/dist/plan/post-logout-uris.js +12 -3
  183. package/dist/plan/private-pages.d.ts +26 -0
  184. package/dist/plan/private-pages.js +39 -0
  185. package/dist/plan/providers-readers.js +20 -1
  186. package/dist/plan/proxy-labels.d.ts +12 -1
  187. package/dist/plan/proxy-labels.js +32 -2
  188. package/dist/plan/release-host.d.ts +6 -2
  189. package/dist/plan/release-host.js +36 -8
  190. package/dist/plan/stage-env.d.ts +19 -1
  191. package/dist/plan/stage-env.js +25 -1
  192. package/dist/plan/stage-guards.d.ts +20 -0
  193. package/dist/plan/stage-guards.js +24 -0
  194. package/dist/plan/stage-isolation.d.ts +24 -0
  195. package/dist/plan/stage-isolation.js +29 -1
  196. package/dist/plan/worker-app-database.d.ts +46 -42
  197. package/dist/plan/worker-app-database.js +150 -153
  198. package/dist/plan/worker-app.d.ts +5 -65
  199. package/dist/plan/worker-app.js +28 -105
  200. package/dist/plan/worker.d.ts +2 -2
  201. package/dist/plan/worker.js +2 -1
  202. package/dist/plan/workspace-client.d.ts +25 -0
  203. package/dist/plan/workspace-client.js +48 -0
  204. package/dist/plan/workspace-sign-in.d.ts +55 -0
  205. package/dist/plan/workspace-sign-in.js +273 -0
  206. package/dist/plan/workspace-worker.d.ts +4 -0
  207. package/dist/plan/workspace-worker.js +13 -0
  208. package/dist/plan/workspace.d.ts +101 -0
  209. package/dist/plan/workspace.js +305 -0
  210. package/dist/release/decide.d.ts +30 -1
  211. package/dist/release/decide.js +73 -11
  212. package/dist/release/health.d.ts +8 -3
  213. package/dist/release/health.js +58 -5
  214. package/dist/release/migration-compatibility.d.ts +66 -0
  215. package/dist/release/migration-compatibility.js +340 -0
  216. package/dist/release/run.d.ts +2 -5
  217. package/dist/release/run.js +4 -0
  218. package/dist/release/state.d.ts +3 -1
  219. package/dist/release/state.js +6 -0
  220. package/dist/release/types.d.ts +20 -0
  221. package/dist/requested-changes.d.ts +11 -2
  222. package/dist/requested-changes.js +13 -1
  223. package/dist/service-labels.d.ts +4 -0
  224. package/dist/service-labels.js +11 -0
  225. package/dist/slug.d.ts +2 -0
  226. package/dist/slug.js +20 -7
  227. package/dist/store/postgres.js +25 -2
  228. package/dist/store/schema.d.ts +73 -1
  229. package/dist/store/schema.js +10 -0
  230. package/dist/worker-app-config.d.ts +1 -11
  231. package/dist/worker-app-config.js +4 -17
  232. package/dist/worker-app-migrations.d.ts +105 -0
  233. package/dist/worker-app-migrations.js +166 -0
  234. package/dist/worker-app-upload.d.ts +103 -0
  235. package/dist/worker-app-upload.js +128 -0
  236. package/package.json +19 -2
@@ -4,6 +4,8 @@ import type { HealthRead } from './decide.js';
4
4
  export declare const HEALTH_READ_TIMEOUT_MS = 10000;
5
5
  /** The color's own address check has this long to answer. */
6
6
  export declare const ADDRESS_CHECK_TIMEOUT_MS = 10000;
7
+ /** A health body larger than this is refused unread (a runaway or hostile response must not fill memory). */
8
+ export declare const HEALTH_BODY_MAX_BYTES: number;
7
9
  /** C1's 200 body. */
8
10
  export declare const healthBody: z.ZodObject<{
9
11
  colors: z.ZodObject<{
@@ -40,14 +42,17 @@ export declare function readReleaseHealth(input: {
40
42
  fetchImpl?: typeof fetch;
41
43
  }): Promise<HealthRead | null>;
42
44
  /**
43
- * An HTTP check of the color's health path on its own address. A redirect is a failure: it is how a login
44
- * wall (Cloudflare Access) answers a machine. With `expectJson` the body must also parse as JSON, so a login page served with 200 is not read as a healthy color.
45
+ * An HTTP check of the color's health path on its own address. A redirect to another origin is a failure: it is
46
+ * how a login wall (Cloudflare Access) answers a machine. One same-origin redirect (a trailing-slash 308) is
47
+ * followed and the answer judged; a second redirect fails. The body is read under {@link HEALTH_BODY_MAX_BYTES}.
48
+ * With `expectJson` it must parse as JSON. Without, it must not be Access's login page (a 200 that
49
+ * carries `cloudflareaccess.com` or `/cdn-cgi/access/`), so a login wall is never read as a healthy static app.
45
50
  */
46
51
  export declare function checkColorAddress(input: {
47
52
  address: string;
48
53
  healthPath: string | null;
49
54
  headers?: Record<string, string>;
50
- /** True for an app whose health path answers JSON; false for a static app's `/`. */
55
+ /** True for an app with a server, whose health path answers JSON; false for a static app's `/`. */
51
56
  expectJson?: boolean;
52
57
  fetchImpl?: typeof fetch;
53
58
  }): Promise<'ok' | 'failed'>;
@@ -3,6 +3,36 @@ import { z } from 'zod';
3
3
  export const HEALTH_READ_TIMEOUT_MS = 10_000;
4
4
  /** The color's own address check has this long to answer. */
5
5
  export const ADDRESS_CHECK_TIMEOUT_MS = 10_000;
6
+ /** A health body larger than this is refused unread (a runaway or hostile response must not fill memory). */
7
+ export const HEALTH_BODY_MAX_BYTES = 64 * 1024;
8
+ /**
9
+ * The response body as text, or null when it is larger than {@link HEALTH_BODY_MAX_BYTES}. Reads in chunks and
10
+ * stops at the cap, so an endless body is never held.
11
+ */
12
+ async function readCappedText(res) {
13
+ const declared = Number(res.headers.get('content-length'));
14
+ if (Number.isFinite(declared) && declared > HEALTH_BODY_MAX_BYTES)
15
+ return null;
16
+ if (!res.body)
17
+ return '';
18
+ const reader = res.body.getReader();
19
+ const chunks = [];
20
+ let total = 0;
21
+ for (;;) {
22
+ const { done, value } = await reader.read();
23
+ if (done)
24
+ break;
25
+ total += value.byteLength;
26
+ if (total > HEALTH_BODY_MAX_BYTES) {
27
+ await reader.cancel().catch(() => undefined);
28
+ return null;
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ return new TextDecoder().decode(Buffer.concat(chunks));
33
+ }
34
+ /** What Cloudflare Access's login page carries: its hosted domain and its path prefix. */
35
+ const ACCESS_PAGE_MARKERS = ['cloudflareaccess.com', '/cdn-cgi/access/'];
6
36
  const counts = z.object({
7
37
  requests: z.number().int().nonnegative(),
8
38
  errors: z.number().int().nonnegative(),
@@ -35,7 +65,10 @@ export async function readReleaseHealth(input) {
35
65
  });
36
66
  if (!res.ok)
37
67
  return null;
38
- const parsed = healthBody.safeParse(await res.json());
68
+ const text = await readCappedText(res);
69
+ if (text === null)
70
+ return null;
71
+ const parsed = healthBody.safeParse(JSON.parse(text));
39
72
  if (!parsed.success)
40
73
  return null;
41
74
  return { colors: parsed.data.colors, daily: parsed.data.daily };
@@ -46,20 +79,40 @@ export async function readReleaseHealth(input) {
46
79
  }
47
80
  }
48
81
  /**
49
- * An HTTP check of the color's health path on its own address. A redirect is a failure: it is how a login
50
- * wall (Cloudflare Access) answers a machine. With `expectJson` the body must also parse as JSON, so a login page served with 200 is not read as a healthy color.
82
+ * An HTTP check of the color's health path on its own address. A redirect to another origin is a failure: it is
83
+ * how a login wall (Cloudflare Access) answers a machine. One same-origin redirect (a trailing-slash 308) is
84
+ * followed and the answer judged; a second redirect fails. The body is read under {@link HEALTH_BODY_MAX_BYTES}.
85
+ * With `expectJson` it must parse as JSON. Without, it must not be Access's login page (a 200 that
86
+ * carries `cloudflareaccess.com` or `/cdn-cgi/access/`), so a login wall is never read as a healthy static app.
51
87
  */
52
88
  export async function checkColorAddress(input) {
53
89
  try {
54
- const res = await (input.fetchImpl ?? fetch)(new URL(input.healthPath ?? '/', input.address), {
90
+ const doFetch = input.fetchImpl ?? fetch;
91
+ const get = (url) => doFetch(url, {
55
92
  ...(input.headers ? { headers: input.headers } : {}),
56
93
  redirect: 'manual',
57
94
  signal: AbortSignal.timeout(ADDRESS_CHECK_TIMEOUT_MS),
58
95
  });
96
+ const first = new URL(input.healthPath ?? '/', input.address);
97
+ let res = await get(first);
98
+ if (res.status >= 300 && res.status < 400) {
99
+ const location = res.headers.get('location');
100
+ if (!location)
101
+ return 'failed';
102
+ const next = new URL(location, first);
103
+ if (next.origin !== first.origin)
104
+ return 'failed';
105
+ res = await get(next);
106
+ }
59
107
  if (!res.ok)
60
108
  return 'failed';
109
+ const text = await readCappedText(res);
110
+ if (text === null)
111
+ return 'failed';
61
112
  if (input.expectJson)
62
- await res.json();
113
+ JSON.parse(text);
114
+ else if (ACCESS_PAGE_MARKERS.some((marker) => text.includes(marker)))
115
+ return 'failed';
63
116
  return 'ok';
64
117
  }
65
118
  catch {
@@ -0,0 +1,66 @@
1
+ import { type WorkerAppConfig } from '../worker-app-config.js';
2
+ /**
3
+ * Whether a release's database changes may go to idle (foundry#934, estate DECISIONS.md,
4
+ * 2026-10-21 "Releases and versions"). Idle shares the live database with whatever release is
5
+ * live, so a release may only add -- never drop, narrow, rename or retype something the live
6
+ * release still reads or writes, and never delete a Durable Object class the live release still
7
+ * uses. A removal needs two releases: one that stops using the object, a later one -- once that
8
+ * first release is no longer on live, idle or draining anywhere -- that removes it.
9
+ *
10
+ * Pure: no database connection, no network call, no data read. The caller supplies everything it
11
+ * already has: what the previous live release applied (`liveMigrationIds`, `liveDoTag`), what
12
+ * release tags are deployed anywhere right now (`deployedReleases`), and which removals this
13
+ * release declares as the drained second step of an earlier one (`declaredRemovals`). There is no
14
+ * override beyond that declaration: a destructive step with no declaration, or whose declared
15
+ * release has not drained, is refused every time.
16
+ *
17
+ * A SQL statement declares its own removal with a `-- removal-since: <release>` comment directly
18
+ * above it (the same convention `@wtfalch/db`'s migration safety check uses for
19
+ * `-- destructive: <reason>`); a Durable Object class removal is declared in `declaredRemovals`,
20
+ * since `deleted_classes` carries no comment of its own.
21
+ *
22
+ * The SQL classifier is pattern-based, not a real SQL parser: it is a safety rail against an
23
+ * accidental incompatible release, not a boundary against someone deliberately hiding a drop
24
+ * inside a string. A statement it cannot classify is refused rather than guessed as safe.
25
+ */
26
+ /** A Durable Object class removal this release declares as the drained second step of an earlier one. */
27
+ export interface DeclaredClassRemoval {
28
+ /** The class name a `deleted_classes` step of this release removes. */
29
+ className: string;
30
+ /** The earlier release that stopped using the class. Must have fully drained before this passes. */
31
+ sinceRelease: string;
32
+ }
33
+ export interface MigrationCompatibilityInput {
34
+ /** This release's own `migrations.json` (SQL), or `null` for a release with none. */
35
+ migrationsJson: string | null;
36
+ /** The SQL migration ids the previous live release already applied, in order. Default: none. */
37
+ liveMigrationIds?: readonly string[];
38
+ /** This release's full, declared Durable Object migrations, in order. Default: none. */
39
+ doMigrations?: WorkerAppConfig['durableObjects']['migrations'];
40
+ /** The Durable Object migration tag the previous live release holds, if any. */
41
+ liveDoTag?: string;
42
+ /** Durable Object class removals this release declares. Default: none. */
43
+ declaredRemovals?: readonly DeclaredClassRemoval[];
44
+ /** Release tags that are, right now, on live, idle or draining anywhere for this app. Default: none. */
45
+ deployedReleases?: readonly string[];
46
+ }
47
+ export interface MigrationCompatibilityProblem {
48
+ /** The SQL migration id or Durable Object migration tag the statement is in. */
49
+ migration: string;
50
+ /** The statement or step that is refused, trimmed to one line. */
51
+ statement: string;
52
+ /** Why it is refused, and what to do about it, in plain words. */
53
+ reason: string;
54
+ }
55
+ export type MigrationCompatibilityResult = {
56
+ ok: true;
57
+ } | {
58
+ ok: false;
59
+ problems: MigrationCompatibilityProblem[];
60
+ };
61
+ /**
62
+ * Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
63
+ * previous live release already applied and which releases are deployed anywhere right now. See
64
+ * the module docstring for the rule and the removal convention.
65
+ */
66
+ export declare function checkMigrationCompatibility(input: MigrationCompatibilityInput): MigrationCompatibilityResult;
@@ -0,0 +1,340 @@
1
+ import { foldMigrationClasses } from '../worker-app-config.js';
2
+ import { parseMigrations, unappliedDatabaseMigrations, } from '../worker-app-migrations.js';
3
+ const REMOVAL_MARKER = /^--\s*removal-since:\s*(\S.*)$/i;
4
+ const STATEMENT_MAX_CHARS = 160;
5
+ /** Collapses whitespace and cuts a statement down to a readable, one-line length for a message. */
6
+ function oneLine(text) {
7
+ const collapsed = text.replace(/\s+/g, ' ').trim();
8
+ return collapsed.length > STATEMENT_MAX_CHARS
9
+ ? `${collapsed.slice(0, STATEMENT_MAX_CHARS - 1)}…`
10
+ : collapsed;
11
+ }
12
+ /**
13
+ * Splits `sql` into standalone statements at a semicolon that sits outside a line comment, a
14
+ * block comment, a single- or double-quoted literal, and a dollar-quoted body (a function's
15
+ * `$$ ... $$`, which may itself hold semicolons). Each statement keeps any comment lines directly
16
+ * above it, so a `-- removal-since:` marker travels with the statement it excuses.
17
+ */
18
+ function splitStatements(sql) {
19
+ const statements = [];
20
+ let current = '';
21
+ let i = 0;
22
+ const n = sql.length;
23
+ while (i < n) {
24
+ const ch = sql[i];
25
+ if (ch === '-' && sql[i + 1] === '-') {
26
+ const end = sql.indexOf('\n', i);
27
+ const end2 = end === -1 ? n : end + 1;
28
+ current += sql.slice(i, end2);
29
+ i = end2;
30
+ continue;
31
+ }
32
+ if (ch === '/' && sql[i + 1] === '*') {
33
+ const end = sql.indexOf('*/', i + 2);
34
+ const end2 = end === -1 ? n : end + 2;
35
+ current += sql.slice(i, end2);
36
+ i = end2;
37
+ continue;
38
+ }
39
+ if (ch === "'" || ch === '"') {
40
+ let j = i + 1;
41
+ while (j < n) {
42
+ if (sql[j] === ch) {
43
+ if (sql[j + 1] === ch) {
44
+ j += 2;
45
+ continue;
46
+ }
47
+ j += 1;
48
+ break;
49
+ }
50
+ j += 1;
51
+ }
52
+ current += sql.slice(i, j);
53
+ i = j;
54
+ continue;
55
+ }
56
+ if (ch === '$') {
57
+ const tagMatch = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
58
+ if (tagMatch) {
59
+ const tag = tagMatch[0];
60
+ const end = sql.indexOf(tag, i + tag.length);
61
+ const end2 = end === -1 ? n : end + tag.length;
62
+ current += sql.slice(i, end2);
63
+ i = end2;
64
+ continue;
65
+ }
66
+ }
67
+ if (ch === ';') {
68
+ statements.push(current);
69
+ current = '';
70
+ i += 1;
71
+ continue;
72
+ }
73
+ current += ch;
74
+ i += 1;
75
+ }
76
+ if (current.trim() !== '')
77
+ statements.push(current);
78
+ return statements;
79
+ }
80
+ /**
81
+ * Splits `code` at a top-level comma (outside a quoted literal or parentheses), so each element
82
+ * of an `ALTER TABLE`'s comma-separated action list -- `ADD COLUMN a text DEFAULT 'x', ADD COLUMN
83
+ * b integer NOT NULL` -- can be judged on its own. Without this, a `DEFAULT` on one added column
84
+ * hid a `NOT NULL` with no default on a different one, because the two regexes ran against the
85
+ * whole statement (foundry#941 review).
86
+ */
87
+ function splitTopLevelCommas(code) {
88
+ const parts = [];
89
+ let current = '';
90
+ let depth = 0;
91
+ let i = 0;
92
+ const n = code.length;
93
+ while (i < n) {
94
+ const ch = code[i];
95
+ if (ch === "'" || ch === '"') {
96
+ let j = i + 1;
97
+ while (j < n) {
98
+ if (code[j] === ch) {
99
+ if (code[j + 1] === ch) {
100
+ j += 2;
101
+ continue;
102
+ }
103
+ j += 1;
104
+ break;
105
+ }
106
+ j += 1;
107
+ }
108
+ current += code.slice(i, j);
109
+ i = j;
110
+ continue;
111
+ }
112
+ if (ch === '(') {
113
+ depth += 1;
114
+ current += ch;
115
+ i += 1;
116
+ continue;
117
+ }
118
+ if (ch === ')') {
119
+ depth = Math.max(0, depth - 1);
120
+ current += ch;
121
+ i += 1;
122
+ continue;
123
+ }
124
+ if (ch === ',' && depth === 0) {
125
+ parts.push(current);
126
+ current = '';
127
+ i += 1;
128
+ continue;
129
+ }
130
+ current += ch;
131
+ i += 1;
132
+ }
133
+ parts.push(current);
134
+ return parts;
135
+ }
136
+ /** The statement with its leading blank and comment lines removed, so classification reads code, not a marker. */
137
+ function withoutLeadingComments(statement) {
138
+ const lines = statement.split('\n');
139
+ let i = 0;
140
+ while (i < lines.length) {
141
+ const trimmed = lines[i].trim();
142
+ if (trimmed === '' || trimmed.startsWith('--')) {
143
+ i += 1;
144
+ continue;
145
+ }
146
+ break;
147
+ }
148
+ return lines.slice(i).join('\n').trim();
149
+ }
150
+ /** The release named in a `-- removal-since: <release>` line directly above the statement, or `null`. */
151
+ function declaredSince(statement) {
152
+ for (const line of statement.split('\n')) {
153
+ const trimmed = line.trim();
154
+ if (trimmed === '')
155
+ continue;
156
+ const match = REMOVAL_MARKER.exec(trimmed);
157
+ if (match)
158
+ return match[1].trim();
159
+ if (!trimmed.startsWith('--'))
160
+ break;
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Classifies one statement's code (comments already stripped) as additive or destructive, or
166
+ * `null` when it cannot tell: an unrecognised statement is never assumed to be additive.
167
+ */
168
+ function classifyStatement(code) {
169
+ if (code === '')
170
+ return null;
171
+ if (/^CREATE\s+(OR\s+REPLACE\s+)?(TABLE|SCHEMA|EXTENSION|SEQUENCE|VIEW)\b/i.test(code)) {
172
+ return { destructive: false, reason: 'creates something new' };
173
+ }
174
+ if (/^CREATE\s+(UNIQUE\s+)?INDEX(\s+CONCURRENTLY)?\b/i.test(code)) {
175
+ return { destructive: false, reason: 'adds an index' };
176
+ }
177
+ if (/^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b/i.test(code)) {
178
+ return { destructive: false, reason: 'adds or replaces a function' };
179
+ }
180
+ if (/^CREATE\s+(OR\s+REPLACE\s+)?TRIGGER\b/i.test(code)) {
181
+ return { destructive: false, reason: 'adds a trigger' };
182
+ }
183
+ if (/^(COMMENT\s+ON|GRANT)\b/i.test(code)) {
184
+ return {
185
+ destructive: false,
186
+ reason: 'changes neither data nor structure a running release reads',
187
+ };
188
+ }
189
+ if (/^ALTER\s+TABLE\b/i.test(code)) {
190
+ if (/\bDROP\s+COLUMN\b/i.test(code))
191
+ return { destructive: true, reason: 'drops a column' };
192
+ if (/\bDROP\s+CONSTRAINT\b/i.test(code))
193
+ return { destructive: true, reason: 'drops a constraint' };
194
+ if (/\bRENAME\b/i.test(code))
195
+ return { destructive: true, reason: 'renames a table or column' };
196
+ if (/\bALTER\s+COLUMN\b[\s\S]*\b(SET\s+DATA\s+)?TYPE\b/i.test(code)) {
197
+ return { destructive: true, reason: "changes a column's type" };
198
+ }
199
+ if (/\bSET\s+NOT\s+NULL\b/i.test(code)) {
200
+ return { destructive: true, reason: 'adds a NOT NULL constraint to an existing column' };
201
+ }
202
+ if (/\bADD\s+COLUMN\b/i.test(code)) {
203
+ for (const clause of splitTopLevelCommas(code)) {
204
+ if (!/\bADD\s+COLUMN\b/i.test(clause))
205
+ continue;
206
+ if (/\bNOT\s+NULL\b/i.test(clause) && !/\bDEFAULT\b/i.test(clause)) {
207
+ return { destructive: true, reason: 'adds a NOT NULL column with no default' };
208
+ }
209
+ }
210
+ return { destructive: false, reason: 'adds a column' };
211
+ }
212
+ if (/\bADD\s+CONSTRAINT\b/i.test(code))
213
+ return { destructive: false, reason: 'adds a constraint' };
214
+ return null;
215
+ }
216
+ if (/^DROP\s+(TABLE|INDEX|TYPE|SCHEMA|FUNCTION|PROCEDURE|TRIGGER|VIEW|MATERIALIZED\s+VIEW|EXTENSION|SEQUENCE)\b/i.test(code)) {
217
+ return { destructive: true, reason: 'drops something the live release may still read' };
218
+ }
219
+ if (/^(TRUNCATE|DELETE\s+FROM|UPDATE\s+\S+\s+SET)\b/i.test(code)) {
220
+ return { destructive: true, reason: 'removes or rewrites existing rows' };
221
+ }
222
+ if (/^ALTER\s+(INDEX|SEQUENCE|VIEW|TYPE|SCHEMA)\b[\s\S]*\bRENAME\b/i.test(code)) {
223
+ return { destructive: true, reason: 'renames something' };
224
+ }
225
+ return null;
226
+ }
227
+ const splitHelp = (what) => `${what}; split this into two releases: stop using it in one, remove it in a later release once that one is no longer on live, idle or draining`;
228
+ /** A destructive statement or step named `since` is drained once that release deploys nowhere. */
229
+ function hasDrained(since, deployedReleases) {
230
+ return !deployedReleases.includes(since);
231
+ }
232
+ function sqlProblems(migrationsJson, liveMigrationIds, deployedReleases) {
233
+ let declared;
234
+ try {
235
+ declared = parseMigrations(migrationsJson);
236
+ }
237
+ catch (error) {
238
+ return [{ migration: '', statement: '', reason: error.message }];
239
+ }
240
+ let pending;
241
+ try {
242
+ pending = unappliedDatabaseMigrations(declared, liveMigrationIds);
243
+ }
244
+ catch (error) {
245
+ return [{ migration: '', statement: '', reason: error.message }];
246
+ }
247
+ const problems = [];
248
+ for (const migration of pending) {
249
+ for (const statement of splitStatements(migration.sql)) {
250
+ const code = withoutLeadingComments(statement);
251
+ const classified = classifyStatement(code);
252
+ if (classified === null) {
253
+ problems.push({
254
+ migration: migration.id,
255
+ statement: oneLine(code),
256
+ reason: 'this statement is not one foundry recognises as additive; split it into its own release and ask a person',
257
+ });
258
+ continue;
259
+ }
260
+ if (!classified.destructive)
261
+ continue;
262
+ const since = declaredSince(statement);
263
+ if (since === null) {
264
+ problems.push({
265
+ migration: migration.id,
266
+ statement: oneLine(code),
267
+ reason: splitHelp(classified.reason),
268
+ });
269
+ continue;
270
+ }
271
+ if (!hasDrained(since, deployedReleases)) {
272
+ problems.push({
273
+ migration: migration.id,
274
+ statement: oneLine(code),
275
+ reason: `${classified.reason}; declared as the second step after release ${since}, but ${since} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
276
+ });
277
+ }
278
+ }
279
+ }
280
+ return problems;
281
+ }
282
+ function doProblems(doMigrations, liveDoTag, declaredRemovals, deployedReleases) {
283
+ if (doMigrations.length === 0)
284
+ return [];
285
+ let appliedCount = 0;
286
+ if (liveDoTag !== undefined) {
287
+ const index = doMigrations.findIndex((m) => m.tag === liveDoTag);
288
+ if (index < 0) {
289
+ return [
290
+ {
291
+ migration: liveDoTag,
292
+ statement: '',
293
+ reason: "the live release holds a Durable Object migration that is not in this release's declared list; migrations are append-only",
294
+ },
295
+ ];
296
+ }
297
+ appliedCount = index + 1;
298
+ }
299
+ const folded = foldMigrationClasses(doMigrations.slice(0, appliedCount));
300
+ if ('error' in folded)
301
+ return [{ migration: '', statement: '', reason: folded.error }];
302
+ const problems = [];
303
+ for (const migration of doMigrations.slice(appliedCount)) {
304
+ for (const className of migration.deletedClasses ?? []) {
305
+ if (!folded.classes.has(className))
306
+ continue; // never reached live: nothing running still uses it
307
+ const removal = declaredRemovals.find((r) => r.className === className);
308
+ const statement = `deleted_classes: ${className}`;
309
+ if (!removal) {
310
+ problems.push({
311
+ migration: migration.tag,
312
+ statement,
313
+ reason: splitHelp(`deletes the Durable Object class '${className}', which the live release still uses`),
314
+ });
315
+ continue;
316
+ }
317
+ if (!hasDrained(removal.sinceRelease, deployedReleases)) {
318
+ problems.push({
319
+ migration: migration.tag,
320
+ statement,
321
+ reason: `deletes the Durable Object class '${className}', declared as the second step after release ${removal.sinceRelease}, but ${removal.sinceRelease} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
322
+ });
323
+ }
324
+ }
325
+ }
326
+ return problems;
327
+ }
328
+ /**
329
+ * Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
330
+ * previous live release already applied and which releases are deployed anywhere right now. See
331
+ * the module docstring for the rule and the removal convention.
332
+ */
333
+ export function checkMigrationCompatibility(input) {
334
+ const deployedReleases = input.deployedReleases ?? [];
335
+ const problems = [
336
+ ...sqlProblems(input.migrationsJson, input.liveMigrationIds ?? [], deployedReleases),
337
+ ...doProblems(input.doMigrations ?? [], input.liveDoTag, input.declaredRemovals ?? [], deployedReleases),
338
+ ];
339
+ return problems.length === 0 ? { ok: true } : { ok: false, problems };
340
+ }
@@ -1,4 +1,4 @@
1
- import { type Decision, type Observation, type ReleaseEffect } from './decide.js';
1
+ import { type Decision, type Observation, type ReleaseEffect, type ReleaseEntryConfig } from './decide.js';
2
2
  import { type ReleaseStore } from './state.js';
3
3
  import type { Color } from './types.js';
4
4
  /** What the runner needs from the outside. Each call is bounded by its own timeout. */
@@ -31,10 +31,7 @@ export declare function runRelease(input: {
31
31
  store: ReleaseStore;
32
32
  orgId: string;
33
33
  entryKey: string;
34
- entry: {
35
- steps?: number[];
36
- stepHoldMinutes?: number;
37
- };
34
+ entry: ReleaseEntryConfig;
38
35
  now: Date;
39
36
  deps: ReleaseDeps;
40
37
  /** No reporting health read: only hard fails count (see `decide`). */
@@ -25,6 +25,10 @@ export async function runRelease(input) {
25
25
  actions,
26
26
  steps: input.entry.steps,
27
27
  stepHoldMinutes: input.entry.stepHoldMinutes,
28
+ advance: input.entry.advance,
29
+ minRequests: input.entry.minRequests,
30
+ errorMargin: input.entry.errorMargin,
31
+ staffFirst: input.entry.staffFirst,
28
32
  now,
29
33
  observation: { health, ...seen },
30
34
  ...(input.hardOnly ? { hardOnly: true } : {}),
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { Recorded } from './decide.js';
2
+ import { type Recorded, resolveReleaseConfig } from './decide.js';
3
3
  import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState } from './types.js';
4
4
  /**
5
5
  * The persistence the release runner and Archon share. `getScopeIds` is the
@@ -52,6 +52,8 @@ export declare function readSignal(ids: Record<string, string>, entryKey: string
52
52
  export declare function readReleaseState(store: ReleaseStore, input: {
53
53
  orgId: string;
54
54
  entryKey: string;
55
+ /** The entry's `blueGreen` as declared; defaults are resolved into `config`. */
56
+ entry?: Parameters<typeof resolveReleaseConfig>[0];
55
57
  }): Promise<ReleaseState | null>;
56
58
  declare const actionSchema: z.ZodType<ReleaseAction>;
57
59
  /** C2: ask the runner to apply an action on its next run. Archon audits it. */
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { STAGE_REF_PATTERN, resolveReleaseConfig } from './decide.js';
2
3
  /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
3
4
  export function releaseKeys(entry) {
4
5
  if (!/^[a-z0-9][a-z0-9-]*$/.test(entry))
@@ -33,6 +34,7 @@ const storedWindow = z.discriminatedUnion('kind', [
33
34
  z.object({ kind: z.literal('passed'), at: z.string() }),
34
35
  z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
35
36
  z.object({ kind: z.literal('cap'), since: z.string() }),
37
+ z.object({ kind: z.literal('staff'), since: z.string() }),
36
38
  ]);
37
39
  const releaseField = z
38
40
  .object({
@@ -110,6 +112,7 @@ export async function readReleaseState(store, input) {
110
112
  window: r.window,
111
113
  signal: readSignal(ids, input.entryKey),
112
114
  held: r.held,
115
+ config: resolveReleaseConfig(input.entry),
113
116
  };
114
117
  }
115
118
  const actionSchema = z.discriminatedUnion('kind', [
@@ -117,6 +120,9 @@ const actionSchema = z.discriminatedUnion('kind', [
117
120
  z.object({ kind: z.literal('rollback') }),
118
121
  z.object({ kind: z.literal('clear_held'), digest: z.string().min(1) }),
119
122
  z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
123
+ z.object({ kind: z.literal('approve_step') }),
124
+ z.object({ kind: z.literal('open') }),
125
+ z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
120
126
  ]);
121
127
  /** C2: ask the runner to apply an action on its next run. Archon audits it. */
122
128
  export async function requestReleaseAction(store, input) {
@@ -22,6 +22,11 @@ export type WindowState = {
22
22
  } | {
23
23
  kind: 'cap';
24
24
  since: string;
25
+ }
26
+ /** Deployed on the idle color at weight 0, waiting for an `open` action (staffFirst). */
27
+ | {
28
+ kind: 'staff';
29
+ since: string;
25
30
  };
26
31
  export interface ReleaseSignal {
27
32
  requests: number;
@@ -29,6 +34,13 @@ export interface ReleaseSignal {
29
34
  baselineRate: number | null;
30
35
  verdict: 'pass' | 'fail' | 'hard_fail' | 'inconclusive';
31
36
  }
37
+ /** The release kind of an entry, defaults resolved. */
38
+ export interface ReleaseConfig {
39
+ advance: 'time' | 'signal' | 'approval';
40
+ steps: number[];
41
+ stepHoldMinutes: number;
42
+ staffFirst: boolean;
43
+ }
32
44
  export interface ReleaseState {
33
45
  entryKey: string;
34
46
  live: Color;
@@ -38,6 +50,7 @@ export interface ReleaseState {
38
50
  window: WindowState;
39
51
  signal: ReleaseSignal | null;
40
52
  held: string[];
53
+ config: ReleaseConfig;
41
54
  }
42
55
  export type ReleaseAction = {
43
56
  kind: 'promote';
@@ -49,6 +62,13 @@ export type ReleaseAction = {
49
62
  } | {
50
63
  kind: 'decide_at_cap';
51
64
  decision: 'pass' | 'rollback';
65
+ } | {
66
+ kind: 'approve_step';
67
+ } | {
68
+ kind: 'open';
69
+ } | {
70
+ kind: 'deploy_stage';
71
+ ref: string;
52
72
  };
53
73
  export declare const COLORS: readonly Color[];
54
74
  export declare function otherColor(color: Color): Color;