@wtfalch/foundry 0.73.0 → 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.
@@ -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.73.0",
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",