@wtfalch/foundry 0.72.3 → 0.74.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.
@@ -27,6 +27,8 @@ export function releaseKeys(entry) {
27
27
  stageCopyRetry: `${p}.stagecopyretry`,
28
28
  /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
29
29
  refused: `${p}.refused`,
30
+ /** `<request id>|<stage deploys tried>|<release number or empty>`: where the go-live being run stands. */
31
+ goLive: `${p}.golive`,
30
32
  };
31
33
  }
32
34
  export function formatWeights(w) {
@@ -80,6 +82,11 @@ export const RELEASE_EVENT_KINDS = [
80
82
  'hard_fail',
81
83
  'fanout_done',
82
84
  'fanout_failed',
85
+ 'scheduled',
86
+ 'schedule_cancelled',
87
+ 'released',
88
+ 'go_live_failed',
89
+ 'go_live_refused',
83
90
  ];
84
91
  /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
85
92
  export const releaseEventPayload = z.object({
@@ -205,6 +212,10 @@ function stageDeployOf(value) {
205
212
  stageDeploy: { at: m[1], outcome: m[2], detail: m[3] },
206
213
  };
207
214
  }
215
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
216
+ /** A tag as a release names it: no `|`, `/` or space, so it fits a key and a `|` separated value. */
217
+ const RELEASE_TAG = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/);
218
+ const BUILTIN_ENTRIES = ['boule', 'agora'];
208
219
  const actionSchema = z.discriminatedUnion('kind', [
209
220
  z.object({ kind: z.literal('promote') }),
210
221
  z.object({ kind: z.literal('rollback') }),
@@ -219,11 +230,112 @@ const actionSchema = z.discriminatedUnion('kind', [
219
230
  z.object({ kind: z.literal('copy_prod_to_stage') }),
220
231
  z.object({ kind: z.literal('undo_stage_copy') }),
221
232
  z.object({ kind: z.literal('fanout_retry') }),
233
+ z.object({
234
+ kind: z.literal('go_live'),
235
+ tag: RELEASE_TAG,
236
+ ref: z.string().regex(COMMIT_SHA),
237
+ required: z.boolean(),
238
+ requires: z.array(z.enum(['data-service'])),
239
+ }),
240
+ z.object({ kind: z.literal('set_required'), tag: RELEASE_TAG, required: z.boolean() }),
222
241
  ]);
223
- /** C2: ask the runner to apply an action on its next run. Archon audits it. */
242
+ const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
243
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. `notBefore` (a `go_live` only) waits for that time. */
224
244
  export async function requestReleaseAction(store, input) {
225
245
  releaseKeys(input.entryKey);
226
- await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
246
+ const action = actionSchema.parse(input.action);
247
+ assertBuiltinAction(input, action);
248
+ if (action.kind === 'set_required')
249
+ await assertReleased(store, input, action.tag);
250
+ if (action.kind === 'go_live' && input.notBefore === undefined) {
251
+ throw new Error('go_live needs a time');
252
+ }
253
+ if (input.notBefore !== undefined)
254
+ assertSchedule(action, input.notBefore, input.now);
255
+ const { ownerOrgId: _owner, now: _now, ...rest } = input;
256
+ await store.addRequest({ ...rest, action });
257
+ }
258
+ function assertBuiltinAction(input, action) {
259
+ if (action.kind !== 'go_live' && action.kind !== 'set_required')
260
+ return;
261
+ if (!BUILTIN_ENTRIES.includes(input.entryKey)) {
262
+ throw new Error('go_live and set_required belong to the entry boule or agora');
263
+ }
264
+ if (input.ownerOrgId === undefined || input.ownerOrgId !== input.orgId) {
265
+ throw new Error('go_live and set_required belong to the owner org');
266
+ }
267
+ }
268
+ async function assertReleased(store, input, tag) {
269
+ const versions = releasedFrom(await store.getScopeIds(input.orgId), input.entryKey);
270
+ if (!versions.some((v) => v.tag === tag)) {
271
+ throw new Error('set_required needs a tag that went live; change the go-live instead');
272
+ }
273
+ }
274
+ function assertSchedule(action, notBefore, now = new Date()) {
275
+ if (action.kind !== 'go_live')
276
+ throw new Error('only go_live can wait for a time');
277
+ if (!ISO_WITH_ZONE.test(notBefore) || Number.isNaN(Date.parse(notBefore))) {
278
+ throw new Error('invalid release time: use ISO 8601 with Z or an offset');
279
+ }
280
+ if (Date.parse(notBefore) <= now.getTime())
281
+ throw new Error('the release time is in the past');
282
+ }
283
+ /** Moves a queued go-live: cancels `id` and queues `action` at `notBefore` in one transaction. False when `id` is consumed, claimed, cancelled or already due. */
284
+ export async function rescheduleRelease(store, input) {
285
+ releaseKeys(input.entryKey);
286
+ const action = actionSchema.parse(input.action);
287
+ assertBuiltinAction(input, action);
288
+ assertSchedule(action, input.notBefore, input.now);
289
+ const { ownerOrgId: _owner, ...rest } = input;
290
+ return store.rescheduleRequest({ ...rest, action });
291
+ }
292
+ const RELEASED_PREFIX = 'released.';
293
+ /** The applied-state key of one released version: `released.<app>.<release>`. */
294
+ export function releasedKey(app, release) {
295
+ return `${RELEASED_PREFIX}${app}.${release}`;
296
+ }
297
+ /** `tag|sha|required|requires|at`; `requires` is comma separated. */
298
+ export function formatReleased(v) {
299
+ return [v.tag, v.sha, String(v.required), v.requires.join(','), v.at].join('|');
300
+ }
301
+ /** Reads one `released.*` value; a damaged value reads as absent. */
302
+ export function parseReleased(app, release, value) {
303
+ const parts = (value ?? '').split('|');
304
+ if (parts.length !== 5)
305
+ return null;
306
+ const [tag, sha, required, requires, at] = parts;
307
+ if (!RELEASE_TAG.safeParse(tag).success || !COMMIT_SHA.test(sha))
308
+ return null;
309
+ if (required !== 'true' && required !== 'false')
310
+ return null;
311
+ if (Number.isNaN(Date.parse(at)))
312
+ return null;
313
+ const list = requires === '' ? [] : requires.split(',');
314
+ if (!list.every((r) => r === 'data-service'))
315
+ return null;
316
+ return { app, tag, sha, release, required: required === 'true', requires: list, at };
317
+ }
318
+ /** The released versions of `app` in the owner's applied state, newest first. */
319
+ export function releasedFrom(ids, app) {
320
+ const prefix = `${RELEASED_PREFIX}${app}.`;
321
+ const out = [];
322
+ for (const key of Object.keys(ids)) {
323
+ if (!key.startsWith(prefix))
324
+ continue;
325
+ const n = key.slice(prefix.length);
326
+ if (!/^[1-9][0-9]{0,11}$/.test(n))
327
+ continue;
328
+ const v = parseReleased(app, Number(n), ids[key]);
329
+ if (v)
330
+ out.push(v);
331
+ }
332
+ return out.sort((a, b) => b.release - a.release);
333
+ }
334
+ /** The tags that went live on the owner's Boule or Agora, newest first. A stage-only build is not in it. */
335
+ export async function readReleasedVersions(store, ownerOrgId, app) {
336
+ if (!BUILTIN_ENTRIES.includes(app))
337
+ throw new Error('invalid released app');
338
+ return releasedFrom(await store.getScopeIds(ownerOrgId), app);
227
339
  }
228
340
  export { actionSchema as releaseActionSchema };
229
341
  export const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
@@ -80,8 +80,11 @@ export interface StageCopyState {
80
80
  outcome: 'ok' | 'refused' | 'failed';
81
81
  detail: string;
82
82
  }
83
- export type ReleaseAction = {
83
+ export type ReleaseAction =
84
+ /** `redeploy`: set by a go-live only (a request cannot carry it); promotes the idle color to the commit the live color already runs. */
85
+ {
84
86
  kind: 'promote';
87
+ redeploy?: boolean;
85
88
  } | {
86
89
  kind: 'rollback';
87
90
  } | {
@@ -111,12 +114,55 @@ export type ReleaseAction = {
111
114
  /** Owner org, entry key `boule` or `agora`: continue the built-in fan-out from the org that failed. */
112
115
  | {
113
116
  kind: 'fanout_retry';
117
+ }
118
+ /**
119
+ * Owner org, entry key `boule` or `agora`: put the tag's commit live. The runner deploys stage at `ref` if stage
120
+ * differs, deploys the idle color with the tag's `APP_*` env, opens it, and records the {@link ReleasedVersion}
121
+ * once the release completes (one color at 100 %, window closed). Usually queued with a time.
122
+ */
123
+ | {
124
+ kind: 'go_live';
125
+ tag: string;
126
+ ref: string;
127
+ required: boolean;
128
+ requires: ReleaseRequirement[];
129
+ }
130
+ /** Owner org, entry key `boule` or `agora`: mark a tag that went live required or not. */
131
+ | {
132
+ kind: 'set_required';
133
+ tag: string;
134
+ required: boolean;
114
135
  };
136
+ /** What a tag needs before a company can take it. `data-service`: the company's data service is applied and its data moved. */
137
+ export type ReleaseRequirement = 'data-service';
138
+ /** One tag that went live on the owner's Boule or Agora. Newest first in {@link readReleasedVersions}. */
139
+ export interface ReleasedVersion {
140
+ app: 'boule' | 'agora';
141
+ tag: string;
142
+ /** The 40-hex commit. */
143
+ sha: string;
144
+ /** The UTC second the idle color was deployed with it. The same number on every copy of the tag. */
145
+ release: number;
146
+ required: boolean;
147
+ requires: ReleaseRequirement[];
148
+ /** ISO time the release completed. */
149
+ at: string;
150
+ }
115
151
  export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
116
152
  /** The built-in fan-out put an org's copy on the live commit (`reason` is the org slug). */
117
153
  | 'fanout_done'
118
154
  /** The built-in fan-out stopped at an org (`reason` is `<slug>: <fixed text>`). */
119
- | 'fanout_failed';
155
+ | 'fanout_failed'
156
+ /** A go-live was queued for a time (`reason` is `<tag> at <ISO time>`). */
157
+ | 'scheduled'
158
+ /** A queued go-live was cancelled before its time (`reason` is the tag). */
159
+ | 'schedule_cancelled'
160
+ /** A tag finished going live: one color at 100 % and the window closed (`reason` is the tag). */
161
+ | 'released'
162
+ /** A step of a go-live did not work and is tried again by the next tick (`reason` is fixed text). */
163
+ | 'go_live_failed'
164
+ /** A go-live was refused and its request consumed (`reason` is fixed text). */
165
+ | 'go_live_refused';
120
166
  /** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
121
167
  export interface ReleaseEvent {
122
168
  at: string;
@@ -1,4 +1,4 @@
1
- import { and, asc, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
1
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lte, ne, or, 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, withNewOrgAppsDefault, } from '../org.js';
3
3
  import { hostingNames } from '../plan/locked-hostnames.js';
4
4
  import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
@@ -1066,6 +1066,46 @@ export function createPostgresAppliedVersionsStore(db) {
1066
1066
  },
1067
1067
  };
1068
1068
  }
1069
+ /** A request row, and for a go-live with a time its `scheduled` line, in the caller's transaction. */
1070
+ async function insertScheduledRequest(tx, input) {
1071
+ const { orgId, entryKey, action, actor, notBefore } = input;
1072
+ await tx.insert(foundryReleaseRequests).values({
1073
+ orgId,
1074
+ entryKey,
1075
+ action,
1076
+ actor,
1077
+ ...(notBefore ? { notBefore: new Date(notBefore) } : {}),
1078
+ });
1079
+ if (notBefore && action.kind === 'go_live') {
1080
+ await tx.insert(foundryReleaseEvents).values({
1081
+ orgId,
1082
+ entryKey,
1083
+ at: new Date(),
1084
+ kind: 'scheduled',
1085
+ payload: { actor, reason: `${action.tag} at ${new Date(notBefore).toISOString()}` },
1086
+ });
1087
+ }
1088
+ }
1089
+ /** Consumes a waiting request in one statement and writes `schedule_cancelled` with it. False when it was not open, was claimed, or is already due. */
1090
+ async function cancelScheduledRequest(tx, input) {
1091
+ const rows = await tx
1092
+ .update(foundryReleaseRequests)
1093
+ .set({ consumedAt: sql `now()` })
1094
+ .where(and(eq(foundryReleaseRequests.id, input.id), eq(foundryReleaseRequests.orgId, input.orgId), eq(foundryReleaseRequests.entryKey, input.entryKey), isNull(foundryReleaseRequests.consumedAt), isNull(foundryReleaseRequests.claimedAt), gt(foundryReleaseRequests.notBefore, input.now ?? sql `now()`)))
1095
+ .returning({ action: foundryReleaseRequests.action });
1096
+ const row = rows[0];
1097
+ if (!row)
1098
+ return false;
1099
+ const tag = row.action.tag;
1100
+ await tx.insert(foundryReleaseEvents).values({
1101
+ orgId: input.orgId,
1102
+ entryKey: input.entryKey,
1103
+ at: new Date(),
1104
+ kind: 'schedule_cancelled',
1105
+ payload: { actor: input.actor, ...(typeof tag === 'string' ? { reason: tag } : {}) },
1106
+ });
1107
+ return true;
1108
+ }
1069
1109
  /**
1070
1110
  * `ReleaseStore` (`../release/state.ts`) on Postgres: the recorded `bg.*`
1071
1111
  * keys come from an org's applied state, or the platform's for the orgId
@@ -1089,18 +1129,69 @@ export function createPostgresReleaseStore(db) {
1089
1129
  .limit(1);
1090
1130
  return row?.ids ?? {};
1091
1131
  },
1092
- async addRequest({ orgId, entryKey, action, actor }) {
1093
- await db.insert(foundryReleaseRequests).values({ orgId, entryKey, action, actor });
1132
+ async addRequest({ orgId, entryKey, action, actor, notBefore }) {
1133
+ await db.transaction(async (tx) => {
1134
+ await insertScheduledRequest(tx, { orgId, entryKey, action, actor, notBefore });
1135
+ });
1094
1136
  },
1095
- async openRequests({ orgId, entryKey }) {
1137
+ async openRequests({ orgId, entryKey, now }) {
1096
1138
  const rows = await db
1097
1139
  .select()
1098
1140
  .from(foundryReleaseRequests)
1099
- .where(and(eq(foundryReleaseRequests.orgId, orgId), eq(foundryReleaseRequests.entryKey, entryKey), isNull(foundryReleaseRequests.consumedAt)))
1141
+ .where(and(eq(foundryReleaseRequests.orgId, orgId), eq(foundryReleaseRequests.entryKey, entryKey), isNull(foundryReleaseRequests.consumedAt), or(isNull(foundryReleaseRequests.notBefore), lte(foundryReleaseRequests.notBefore, now ?? sql `now()`))))
1100
1142
  .orderBy(asc(foundryReleaseRequests.id));
1101
1143
  return rows.flatMap((row) => {
1102
1144
  const action = releaseActionSchema.safeParse(row.action);
1103
- return action.success ? [{ id: row.id, action: action.data, actor: row.actor }] : [];
1145
+ return action.success
1146
+ ? [
1147
+ {
1148
+ id: row.id,
1149
+ action: action.data,
1150
+ actor: row.actor,
1151
+ notBefore: row.notBefore ? row.notBefore.toISOString() : null,
1152
+ },
1153
+ ]
1154
+ : [];
1155
+ });
1156
+ },
1157
+ async claimRequests(ids) {
1158
+ if (ids.length === 0)
1159
+ return;
1160
+ await db
1161
+ .update(foundryReleaseRequests)
1162
+ .set({ claimedAt: sql `now()` })
1163
+ .where(and(inArray(foundryReleaseRequests.id, [...ids]), isNull(foundryReleaseRequests.claimedAt)));
1164
+ },
1165
+ async cancelRequest(input) {
1166
+ return db.transaction((tx) => cancelScheduledRequest(tx, input));
1167
+ },
1168
+ async readScheduled({ orgId, entryKey }) {
1169
+ const rows = await db
1170
+ .select()
1171
+ .from(foundryReleaseRequests)
1172
+ .where(and(eq(foundryReleaseRequests.orgId, orgId), eq(foundryReleaseRequests.entryKey, entryKey), isNull(foundryReleaseRequests.consumedAt), isNotNull(foundryReleaseRequests.notBefore)))
1173
+ .orderBy(asc(foundryReleaseRequests.notBefore), asc(foundryReleaseRequests.id));
1174
+ return rows.flatMap((row) => {
1175
+ const action = releaseActionSchema.safeParse(row.action);
1176
+ return action.success && row.notBefore
1177
+ ? [
1178
+ {
1179
+ id: row.id,
1180
+ action: action.data,
1181
+ notBefore: row.notBefore.toISOString(),
1182
+ actor: row.actor,
1183
+ claimed: row.claimedAt !== null,
1184
+ },
1185
+ ]
1186
+ : [];
1187
+ });
1188
+ },
1189
+ async rescheduleRequest({ id, orgId, entryKey, action, notBefore, actor, now }) {
1190
+ return db.transaction(async (tx) => {
1191
+ if (!(await cancelScheduledRequest(tx, { id, orgId, entryKey, actor, now })))
1192
+ return false;
1193
+ await insertScheduledRequest(tx, { orgId, entryKey, action, actor, notBefore });
1194
+ return true;
1104
1195
  });
1105
1196
  },
1106
1197
  async consumeRequests(ids) {
@@ -2906,6 +2906,40 @@ export declare const foundryReleaseRequests: import("drizzle-orm/pg-core").PgTab
2906
2906
  identity: undefined;
2907
2907
  generated: undefined;
2908
2908
  }, {}, {}>;
2909
+ notBefore: import("drizzle-orm/pg-core").PgColumn<{
2910
+ name: "not_before";
2911
+ tableName: "foundry_release_requests";
2912
+ dataType: "date";
2913
+ columnType: "PgTimestamp";
2914
+ data: Date;
2915
+ driverParam: string;
2916
+ notNull: false;
2917
+ hasDefault: false;
2918
+ isPrimaryKey: false;
2919
+ isAutoincrement: false;
2920
+ hasRuntimeDefault: false;
2921
+ enumValues: undefined;
2922
+ baseColumn: never;
2923
+ identity: undefined;
2924
+ generated: undefined;
2925
+ }, {}, {}>;
2926
+ claimedAt: import("drizzle-orm/pg-core").PgColumn<{
2927
+ name: "claimed_at";
2928
+ tableName: "foundry_release_requests";
2929
+ dataType: "date";
2930
+ columnType: "PgTimestamp";
2931
+ data: Date;
2932
+ driverParam: string;
2933
+ notNull: false;
2934
+ hasDefault: false;
2935
+ isPrimaryKey: false;
2936
+ isAutoincrement: false;
2937
+ hasRuntimeDefault: false;
2938
+ enumValues: undefined;
2939
+ baseColumn: never;
2940
+ identity: undefined;
2941
+ generated: undefined;
2942
+ }, {}, {}>;
2909
2943
  };
2910
2944
  dialect: "pg";
2911
2945
  }>;
@@ -1,4 +1,5 @@
1
- import { bigint, bigserial, boolean, integer, jsonb, pgTable, text, timestamp, } from 'drizzle-orm/pg-core';
1
+ import { sql } from 'drizzle-orm';
2
+ import { bigint, bigserial, boolean, index, integer, jsonb, pgTable, text, timestamp, } from 'drizzle-orm/pg-core';
2
3
  /**
3
4
  * Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
4
5
  * the SQL carries the CHECKs drizzle-kit does not generate here (this
@@ -271,7 +272,15 @@ export const foundryReleaseRequests = pgTable('foundry_release_requests', {
271
272
  actor: text('actor').notNull(),
272
273
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
273
274
  consumedAt: timestamp('consumed_at', { withTimezone: true }),
274
- });
275
+ /** `migrations/0071_release_request_schedule.sql`: null is due at once. */
276
+ notBefore: timestamp('not_before', { withTimezone: true }),
277
+ /** Set when the runner starts a due go-live; a claimed request can no longer be cancelled or moved. */
278
+ claimedAt: timestamp('claimed_at', { withTimezone: true }),
279
+ }, (t) => [
280
+ index('foundry_release_requests_scheduled_idx')
281
+ .on(t.orgId, t.entryKey, t.notBefore)
282
+ .where(sql `consumed_at is null and not_before is not null`),
283
+ ]);
275
284
  /** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
276
285
  export const foundryReleaseEvents = pgTable('foundry_release_events', {
277
286
  id: bigserial('id', { mode: 'number' }).primaryKey(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.72.3",
3
+ "version": "0.74.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",