@wtfalch/foundry 0.16.16 → 0.17.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 (59) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +12 -2
  6. package/dist/index.d.ts +9 -4
  7. package/dist/index.js +7 -0
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/org.d.ts +54 -0
  14. package/dist/org.js +43 -1
  15. package/dist/plan/adopt.d.ts +76 -0
  16. package/dist/plan/adopt.js +184 -0
  17. package/dist/plan/agora.d.ts +21 -0
  18. package/dist/plan/agora.js +37 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +13 -5
  21. package/dist/plan/chat-matrix.d.ts +1 -1
  22. package/dist/plan/chat-matrix.js +11 -3
  23. package/dist/plan/core-files.d.ts +25 -0
  24. package/dist/plan/core-files.js +25 -0
  25. package/dist/plan/diff.d.ts +11 -3
  26. package/dist/plan/diff.js +27 -62
  27. package/dist/plan/entry-ai-key.d.ts +3 -3
  28. package/dist/plan/entry-files-key.d.ts +3 -3
  29. package/dist/plan/executor.d.ts +57 -23
  30. package/dist/plan/executor.js +134 -50
  31. package/dist/plan/index.d.ts +4 -0
  32. package/dist/plan/index.js +2 -0
  33. package/dist/plan/live.d.ts +5 -0
  34. package/dist/plan/login-host.d.ts +1 -1
  35. package/dist/plan/login-host.js +6 -5
  36. package/dist/plan/plan-apply.d.ts +139 -6
  37. package/dist/plan/plan-apply.js +546 -76
  38. package/dist/plan/platform-diff.d.ts +14 -0
  39. package/dist/plan/platform-diff.js +16 -1
  40. package/dist/plan/platform-org.d.ts +16 -0
  41. package/dist/plan/platform-org.js +17 -1
  42. package/dist/plan/platform-providers.d.ts +1 -1
  43. package/dist/plan/platform-providers.js +73 -11
  44. package/dist/plan/providers-readers.d.ts +2 -0
  45. package/dist/plan/providers-readers.js +4 -0
  46. package/dist/plan/rollout.d.ts +52 -0
  47. package/dist/plan/rollout.js +37 -0
  48. package/dist/plan/step-system.d.ts +22 -0
  49. package/dist/plan/step-system.js +37 -0
  50. package/dist/plan.d.ts +3 -0
  51. package/dist/state.d.ts +13 -0
  52. package/dist/store/audit.js +15 -1
  53. package/dist/store/postgres.d.ts +1 -1
  54. package/dist/store/postgres.js +127 -14
  55. package/dist/store/schema.d.ts +134 -5
  56. package/dist/store/schema.js +12 -1
  57. package/dist/store.d.ts +47 -4
  58. package/dist/store.js +9 -1
  59. package/package.json +1 -1
@@ -1,11 +1,31 @@
1
1
  import { and, asc, desc, eq, sql } from 'drizzle-orm';
2
- import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwners, validateSite, } from '../org.js';
2
+ import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateHosting, validateMail, validateOwners, validateSite, } from '../org.js';
3
3
  import { lockedHostnames } from '../plan/locked-hostnames.js';
4
4
  import { validatePlatform } from '../platform.js';
5
5
  import { RequestedChangeError, classifyChange } from '../requested-changes.js';
6
6
  import { validateSlug } from '../slug.js';
7
- import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryRequestedChanges, } from './schema.js';
7
+ import { StaleLeaseError, } from '../store.js';
8
+ import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryRequestedChanges, } from './schema.js';
8
9
  const UNIQUE_VIOLATION = '23505';
10
+ /**
11
+ * #441: runs `write`, and with a `fence` only if its token is still the
12
+ * scope's current one. The lease row is locked for the write, so a takeover
13
+ * cannot slip between the check and the write.
14
+ */
15
+ async function writeFenced(db, fence, write) {
16
+ if (!fence)
17
+ return write(db);
18
+ await db.transaction(async (tx) => {
19
+ const [row] = await tx
20
+ .select({ token: foundryMutationLeases.token })
21
+ .from(foundryMutationLeases)
22
+ .where(eq(foundryMutationLeases.scope, fence.scope))
23
+ .for('update');
24
+ if (!row || row.token !== fence.token)
25
+ throw new StaleLeaseError(fence.scope);
26
+ await write(tx);
27
+ });
28
+ }
9
29
  /** `migrations/0004_slug_unique.sql` / `migrations/0015_domain_unique.sql`'s index names, read back off a caught unique-violation to tell which one fired. */
10
30
  const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
11
31
  const DOMAIN_UNIQUE_INDEX = 'foundry_declared_orgs_domain_idx';
@@ -75,7 +95,21 @@ function rowToOrg(row) {
75
95
  roleMailboxes: row.roleMailboxes,
76
96
  },
77
97
  chat: { enabled: row.chatEnabled, usesDomain: row.chatUsesDomain },
78
- calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
98
+ calls: {
99
+ enabled: row.callsEnabled,
100
+ usesDomain: row.callsUsesDomain,
101
+ // Omitted, not `null`, while neither column is set: every existing
102
+ // row (and every test fixture that builds a `CallsConfig` literal
103
+ // before this field existed) round-trips unchanged.
104
+ ...(row.callsVersionPinSynapse || row.callsVersionPinMas
105
+ ? {
106
+ versionPin: {
107
+ ...(row.callsVersionPinSynapse ? { synapse: row.callsVersionPinSynapse } : {}),
108
+ ...(row.callsVersionPinMas ? { mas: row.callsVersionPinMas } : {}),
109
+ },
110
+ }
111
+ : {}),
112
+ },
79
113
  issuer: { usesDomain: row.issuerUsesDomain },
80
114
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
81
115
  hosting: {
@@ -119,6 +153,8 @@ function orgToRow(org) {
119
153
  chatUsesDomain: org.chat.usesDomain,
120
154
  callsEnabled: org.calls.enabled,
121
155
  callsUsesDomain: org.calls.usesDomain,
156
+ callsVersionPinSynapse: org.calls.versionPin?.synapse ?? null,
157
+ callsVersionPinMas: org.calls.versionPin?.mas ?? null,
122
158
  issuerUsesDomain: org.issuer.usesDomain,
123
159
  siteEnabled: org.site.enabled,
124
160
  siteUsesDomain: org.site.usesDomain,
@@ -268,11 +304,25 @@ export function createPostgresDeclaredOrgStore(db) {
268
304
  // `mail.ownerMailboxAddress` just because mail is on -- `validateMail`'s
269
305
  // own doc comment says why. Also covers #34's `mailApps` check, folded
270
306
  // into the same `validateMail`.
271
- function requireValidMail(org) {
272
- const reason = validateMail(org);
307
+ //
308
+ // #422: `allowExistingTier2` grandfathers an org already stored as a
309
+ // domain plus `mail.mode: 'received'` -- `update` passes it true only
310
+ // when that exact combination was already the row's state before this
311
+ // patch, so an unrelated edit to an already-tier-2 org still goes
312
+ // through. `create` never passes it: a brand new org cannot already be
313
+ // grandfathered.
314
+ function requireValidMail(org, opts) {
315
+ const reason = validateMail(org, opts);
273
316
  if (reason)
274
317
  throw new DeclaredOrgError(`mail invalid: ${reason} (#162)`);
275
318
  }
319
+ // #259: same place `requireValidMail` checks mail, on both `create` and
320
+ // the merged result of `update`.
321
+ function requireValidCalls(org) {
322
+ const reason = validateCalls(org);
323
+ if (reason)
324
+ throw new DeclaredOrgError(`calls invalid: ${reason} (#259)`);
325
+ }
276
326
  // `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
277
327
  return {
278
328
  async create(org) {
@@ -284,6 +334,7 @@ export function createPostgresDeclaredOrgStore(db) {
284
334
  requireValidSite(org);
285
335
  requireValidOwner(org);
286
336
  requireValidMail(org);
337
+ requireValidCalls(org);
287
338
  try {
288
339
  await db.insert(declaredOrgs).values(orgToRow(org));
289
340
  }
@@ -339,6 +390,23 @@ export function createPostgresDeclaredOrgStore(db) {
339
390
  }
340
391
  return null;
341
392
  },
393
+ async findOtherOrgRecordingCoolifyId(id, exceptOrgId) {
394
+ const rows = await db
395
+ .select({ orgId: foundryAppliedState.orgId, serviceIds: foundryAppliedState.serviceIds })
396
+ .from(foundryAppliedState);
397
+ for (const row of rows) {
398
+ if (row.orgId === exceptOrgId)
399
+ continue;
400
+ for (const [key, value] of Object.entries(row.serviceIds)) {
401
+ const coolify = key === 'coolify_project' ||
402
+ key.startsWith('coolify:app:') ||
403
+ key.startsWith('coolify:database:');
404
+ if (coolify && value === id)
405
+ return row.orgId;
406
+ }
407
+ }
408
+ return null;
409
+ },
342
410
  async update(id, patch, opts) {
343
411
  const current = rowToOrg(await requireRow(id));
344
412
  // #351: already behind, so refuse without validating a write that will not happen.
@@ -366,7 +434,11 @@ export function createPostgresDeclaredOrgStore(db) {
366
434
  requireValidHosting(next);
367
435
  requireValidSite(next);
368
436
  requireValidOwner(next);
369
- requireValidMail(next);
437
+ // #422: grandfather an org already stored as domain + `received` --
438
+ // refuse only when this patch is what newly puts it in that state.
439
+ const wasTier2 = current.domain !== null && current.mail.mode === 'received';
440
+ requireValidMail(next, { allowExistingTier2: wasTier2 });
441
+ requireValidCalls(next);
370
442
  try {
371
443
  // #351: with `expectedVersion` the version check is part of this one
372
444
  // statement, so an edit that lands after the read above makes it match
@@ -420,10 +492,44 @@ export function createPostgresDeclaredOrgStore(db) {
420
492
  declaredOrgVersion: row.lastAppliedDeclaredOrgVersion,
421
493
  }
422
494
  : null,
495
+ ...(row.destroy ? { destroy: row.destroy } : {}),
423
496
  };
424
497
  return state;
425
498
  },
426
- async putAppliedState(state) {
499
+ async acquireMutationLease(scope, holder, ttlMs) {
500
+ const expiresAt = sql `now() + ${ttlMs} * interval '1 millisecond'`;
501
+ const [row] = await db
502
+ .insert(foundryMutationLeases)
503
+ .values({ scope, token: 1, holder, expiresAt })
504
+ .onConflictDoUpdate({
505
+ target: foundryMutationLeases.scope,
506
+ set: { token: sql `${foundryMutationLeases.token} + 1`, holder, expiresAt },
507
+ setWhere: sql `${foundryMutationLeases.expiresAt} <= now()`,
508
+ })
509
+ .returning({ token: foundryMutationLeases.token });
510
+ if (row)
511
+ return { scope, token: row.token };
512
+ const [held] = await db
513
+ .select({ expiresAt: foundryMutationLeases.expiresAt })
514
+ .from(foundryMutationLeases)
515
+ .where(eq(foundryMutationLeases.scope, scope));
516
+ return { heldUntil: held?.expiresAt ?? new Date() };
517
+ },
518
+ async renewMutationLease(lease, ttlMs) {
519
+ const rows = await db
520
+ .update(foundryMutationLeases)
521
+ .set({ expiresAt: sql `now() + ${ttlMs} * interval '1 millisecond'` })
522
+ .where(and(eq(foundryMutationLeases.scope, lease.scope), eq(foundryMutationLeases.token, lease.token)))
523
+ .returning({ token: foundryMutationLeases.token });
524
+ return rows.length > 0;
525
+ },
526
+ async releaseMutationLease(lease) {
527
+ await db
528
+ .update(foundryMutationLeases)
529
+ .set({ expiresAt: sql `now()` })
530
+ .where(and(eq(foundryMutationLeases.scope, lease.scope), eq(foundryMutationLeases.token, lease.token)));
531
+ },
532
+ async putAppliedState(state, fence) {
427
533
  const values = {
428
534
  orgId: state.orgId,
429
535
  serviceIds: state.serviceIds,
@@ -431,21 +537,25 @@ export function createPostgresDeclaredOrgStore(db) {
431
537
  failedFields: state.failedFields,
432
538
  lastAppliedAt: state.lastApplied ? new Date(state.lastApplied.at) : null,
433
539
  lastAppliedDeclaredOrgVersion: state.lastApplied?.declaredOrgVersion ?? null,
540
+ destroy: state.destroy ?? null,
434
541
  };
435
- await db
542
+ await writeFenced(db, fence, (tx) => tx
436
543
  .insert(foundryAppliedState)
437
544
  .values(values)
438
- .onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values });
545
+ .onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values })
546
+ .then(() => undefined));
439
547
  },
440
- async delete(id) {
548
+ async delete(id, fence) {
441
549
  // Applied state first: `0002_applied_state.sql`'s
442
550
  // `foundry_applied_state_org_fkey` references the declared org with no
443
551
  // cascade, so deleting the org first would fail while its applied
444
552
  // state row still points at it. Both deletes are no-ops (idempotent)
445
553
  // when the row is already gone, matching this method's own doc
446
554
  // comment on `../store.ts`.
447
- await db.delete(foundryAppliedState).where(eq(foundryAppliedState.orgId, id));
448
- await db.delete(declaredOrgs).where(eq(declaredOrgs.id, id));
555
+ await writeFenced(db, fence, async (tx) => {
556
+ await tx.delete(foundryAppliedState).where(eq(foundryAppliedState.orgId, id));
557
+ await tx.delete(declaredOrgs).where(eq(declaredOrgs.id, id));
558
+ });
449
559
  },
450
560
  };
451
561
  }
@@ -549,8 +659,11 @@ export function createPostgresPlatformStore(db) {
549
659
  .limit(1);
550
660
  return row?.serviceIds ?? {};
551
661
  },
552
- async putServiceIds(serviceIds) {
553
- await db.update(foundryDeclaredPlatform).set({ serviceIds });
662
+ async putServiceIds(serviceIds, fence) {
663
+ await writeFenced(db, fence, (tx) => tx
664
+ .update(foundryDeclaredPlatform)
665
+ .set({ serviceIds })
666
+ .then(() => undefined));
554
667
  },
555
668
  };
556
669
  }
@@ -1,6 +1,6 @@
1
1
  import type { AppEnvSource, HostingEntry, IssuerApp, OwnerIdentity } from '../org.js';
2
2
  import type { Plan } from '../plan.js';
3
- import type { Checkpoint } from '../state.js';
3
+ import type { Checkpoint, DestroyRecord } from '../state.js';
4
4
  /**
5
5
  * Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
6
6
  * the SQL carries the CHECKs drizzle-kit does not generate here (this
@@ -270,6 +270,40 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
270
270
  identity: undefined;
271
271
  generated: undefined;
272
272
  }, {}, {}>;
273
+ callsVersionPinSynapse: import("drizzle-orm/pg-core").PgColumn<{
274
+ name: "calls_version_pin_synapse";
275
+ tableName: "foundry_declared_orgs";
276
+ dataType: "string";
277
+ columnType: "PgText";
278
+ data: string;
279
+ driverParam: string;
280
+ notNull: false;
281
+ hasDefault: false;
282
+ isPrimaryKey: false;
283
+ isAutoincrement: false;
284
+ hasRuntimeDefault: false;
285
+ enumValues: [string, ...string[]];
286
+ baseColumn: never;
287
+ identity: undefined;
288
+ generated: undefined;
289
+ }, {}, {}>;
290
+ callsVersionPinMas: import("drizzle-orm/pg-core").PgColumn<{
291
+ name: "calls_version_pin_mas";
292
+ tableName: "foundry_declared_orgs";
293
+ dataType: "string";
294
+ columnType: "PgText";
295
+ data: string;
296
+ driverParam: string;
297
+ notNull: false;
298
+ hasDefault: false;
299
+ isPrimaryKey: false;
300
+ isAutoincrement: false;
301
+ hasRuntimeDefault: false;
302
+ enumValues: [string, ...string[]];
303
+ baseColumn: never;
304
+ identity: undefined;
305
+ generated: undefined;
306
+ }, {}, {}>;
273
307
  issuerUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
274
308
  name: "issuer_uses_domain";
275
309
  tableName: "foundry_declared_orgs";
@@ -945,6 +979,101 @@ export declare const foundryAppliedState: import("drizzle-orm/pg-core").PgTableW
945
979
  identity: undefined;
946
980
  generated: undefined;
947
981
  }, {}, {}>;
982
+ destroy: import("drizzle-orm/pg-core").PgColumn<{
983
+ name: "destroy";
984
+ tableName: "foundry_applied_state";
985
+ dataType: "json";
986
+ columnType: "PgJsonb";
987
+ data: DestroyRecord;
988
+ driverParam: unknown;
989
+ notNull: false;
990
+ hasDefault: false;
991
+ isPrimaryKey: false;
992
+ isAutoincrement: false;
993
+ hasRuntimeDefault: false;
994
+ enumValues: undefined;
995
+ baseColumn: never;
996
+ identity: undefined;
997
+ generated: undefined;
998
+ }, {}, {
999
+ $type: DestroyRecord;
1000
+ }>;
1001
+ };
1002
+ dialect: "pg";
1003
+ }>;
1004
+ /** Mirrors `migrations/0042_mutation_leases.sql`: one row per lock scope; `token` only ever rises (#441). */
1005
+ export declare const foundryMutationLeases: import("drizzle-orm/pg-core").PgTableWithColumns<{
1006
+ name: "foundry_mutation_leases";
1007
+ schema: undefined;
1008
+ columns: {
1009
+ scope: import("drizzle-orm/pg-core").PgColumn<{
1010
+ name: "scope";
1011
+ tableName: "foundry_mutation_leases";
1012
+ dataType: "string";
1013
+ columnType: "PgText";
1014
+ data: string;
1015
+ driverParam: string;
1016
+ notNull: true;
1017
+ hasDefault: false;
1018
+ isPrimaryKey: true;
1019
+ isAutoincrement: false;
1020
+ hasRuntimeDefault: false;
1021
+ enumValues: [string, ...string[]];
1022
+ baseColumn: never;
1023
+ identity: undefined;
1024
+ generated: undefined;
1025
+ }, {}, {}>;
1026
+ token: import("drizzle-orm/pg-core").PgColumn<{
1027
+ name: "token";
1028
+ tableName: "foundry_mutation_leases";
1029
+ dataType: "number";
1030
+ columnType: "PgBigInt53";
1031
+ data: number;
1032
+ driverParam: string | number;
1033
+ notNull: true;
1034
+ hasDefault: false;
1035
+ isPrimaryKey: false;
1036
+ isAutoincrement: false;
1037
+ hasRuntimeDefault: false;
1038
+ enumValues: undefined;
1039
+ baseColumn: never;
1040
+ identity: undefined;
1041
+ generated: undefined;
1042
+ }, {}, {}>;
1043
+ holder: import("drizzle-orm/pg-core").PgColumn<{
1044
+ name: "holder";
1045
+ tableName: "foundry_mutation_leases";
1046
+ dataType: "string";
1047
+ columnType: "PgText";
1048
+ data: string;
1049
+ driverParam: string;
1050
+ notNull: true;
1051
+ hasDefault: false;
1052
+ isPrimaryKey: false;
1053
+ isAutoincrement: false;
1054
+ hasRuntimeDefault: false;
1055
+ enumValues: [string, ...string[]];
1056
+ baseColumn: never;
1057
+ identity: undefined;
1058
+ generated: undefined;
1059
+ }, {}, {}>;
1060
+ expiresAt: import("drizzle-orm/pg-core").PgColumn<{
1061
+ name: "expires_at";
1062
+ tableName: "foundry_mutation_leases";
1063
+ dataType: "date";
1064
+ columnType: "PgTimestamp";
1065
+ data: Date;
1066
+ driverParam: string;
1067
+ notNull: true;
1068
+ hasDefault: false;
1069
+ isPrimaryKey: false;
1070
+ isAutoincrement: false;
1071
+ hasRuntimeDefault: false;
1072
+ enumValues: undefined;
1073
+ baseColumn: never;
1074
+ identity: undefined;
1075
+ generated: undefined;
1076
+ }, {}, {}>;
948
1077
  };
949
1078
  dialect: "pg";
950
1079
  }>;
@@ -1107,7 +1236,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1107
1236
  tableName: "foundry_audit_events";
1108
1237
  dataType: "string";
1109
1238
  columnType: "PgText";
1110
- data: "plan" | "apply" | "destroy";
1239
+ data: "plan" | "destroy" | "apply";
1111
1240
  driverParam: string;
1112
1241
  notNull: true;
1113
1242
  hasDefault: false;
@@ -1119,7 +1248,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1119
1248
  identity: undefined;
1120
1249
  generated: undefined;
1121
1250
  }, {}, {
1122
- $type: "plan" | "apply" | "destroy";
1251
+ $type: "plan" | "destroy" | "apply";
1123
1252
  }>;
1124
1253
  actorId: import("drizzle-orm/pg-core").PgColumn<{
1125
1254
  name: "actor_id";
@@ -1177,7 +1306,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1177
1306
  tableName: "foundry_audit_events";
1178
1307
  dataType: "string";
1179
1308
  columnType: "PgText";
1180
- data: "applied" | "refused" | "failed" | "destroyed";
1309
+ data: "applied" | "failed" | "refused" | "destroyed";
1181
1310
  driverParam: string;
1182
1311
  notNull: false;
1183
1312
  hasDefault: false;
@@ -1189,7 +1318,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1189
1318
  identity: undefined;
1190
1319
  generated: undefined;
1191
1320
  }, {}, {
1192
- $type: "applied" | "refused" | "failed" | "destroyed";
1321
+ $type: "applied" | "failed" | "refused" | "destroyed";
1193
1322
  }>;
1194
1323
  reason: import("drizzle-orm/pg-core").PgColumn<{
1195
1324
  name: "reason";
@@ -1,4 +1,4 @@
1
- import { bigserial, boolean, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
1
+ import { bigint, bigserial, boolean, integer, jsonb, pgTable, text, timestamp, } from 'drizzle-orm/pg-core';
2
2
  /**
3
3
  * Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
4
4
  * the SQL carries the CHECKs drizzle-kit does not generate here (this
@@ -31,6 +31,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
31
31
  chatUsesDomain: boolean('chat_uses_domain').notNull().default(false),
32
32
  callsEnabled: boolean('calls_enabled').notNull().default(false),
33
33
  callsUsesDomain: boolean('calls_uses_domain').notNull().default(false),
34
+ // #259: null holds no pin -- the stack follows versions.json, same as today.
35
+ callsVersionPinSynapse: text('calls_version_pin_synapse'),
36
+ callsVersionPinMas: text('calls_version_pin_mas'),
34
37
  issuerUsesDomain: boolean('issuer_uses_domain').notNull().default(false),
35
38
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
36
39
  siteEnabled: boolean('site_enabled').notNull().default(false),
@@ -101,6 +104,14 @@ export const foundryAppliedState = pgTable('foundry_applied_state', {
101
104
  failedFields: jsonb('failed_fields').notNull().default([]).$type(),
102
105
  lastAppliedAt: timestamp('last_applied_at', { withTimezone: true }),
103
106
  lastAppliedDeclaredOrgVersion: text('last_applied_declared_org_version'),
107
+ destroy: jsonb('destroy').$type(),
108
+ });
109
+ /** Mirrors `migrations/0042_mutation_leases.sql`: one row per lock scope; `token` only ever rises (#441). */
110
+ export const foundryMutationLeases = pgTable('foundry_mutation_leases', {
111
+ scope: text('scope').primaryKey(),
112
+ token: bigint('token', { mode: 'number' }).notNull(),
113
+ holder: text('holder').notNull(),
114
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
104
115
  });
105
116
  /**
106
117
  * Mirrors `migrations/0036_applied_versions.sql`, the source of truth: an
package/dist/store.d.ts CHANGED
@@ -13,6 +13,24 @@ export interface StaleUpdate {
13
13
  /** The org as it stands now. */
14
14
  current: DeclaredOrg;
15
15
  }
16
+ /**
17
+ * A held mutation lease (#441). `token` rises on every acquire of a scope, so
18
+ * a worker whose lease expired and was taken over holds a token the store no
19
+ * longer accepts: its fenced writes are refused.
20
+ */
21
+ export interface MutationLease {
22
+ scope: string;
23
+ token: number;
24
+ }
25
+ /** What `acquireMutationLease` returns while another holder has the scope: when that lease runs out. */
26
+ export interface HeldLease {
27
+ heldUntil: Date;
28
+ }
29
+ /** A fenced write (`putAppliedState`, `putServiceIds`) came from a lease that is no longer the current one (#441). Nothing was written. */
30
+ export declare class StaleLeaseError extends Error {
31
+ readonly scope: string;
32
+ constructor(scope: string);
33
+ }
16
34
  /**
17
35
  * The persistence seam https://github.com/wtfalch/foundry/issues/18
18
36
  * implements: create, read and edit a {@link DeclaredOrg}, and read and
@@ -44,6 +62,12 @@ export interface DeclaredOrgStore {
44
62
  * record instead.
45
63
  */
46
64
  findOrgIdForZone(zoneId: string): Promise<OrgId | null>;
65
+ /**
66
+ * Archon#271: an org other than `exceptOrgId` whose applied state records
67
+ * `id` as a Coolify app, database or project, or `null`. Adopt refuses a
68
+ * resource another org already holds.
69
+ */
70
+ findOtherOrgRecordingCoolifyId(id: string, exceptOrgId: OrgId): Promise<OrgId | null>;
47
71
  /**
48
72
  * Combinations foundry knows are impossible are rejected with a reason
49
73
  * (#18 "Done when"), so a caller must be ready for this to throw rather
@@ -58,7 +82,26 @@ export interface DeclaredOrgStore {
58
82
  */
59
83
  update(id: OrgId, patch: Partial<DeclaredOrg>, opts: UpdateOptions): Promise<DeclaredOrg | StaleUpdate>;
60
84
  getAppliedState(id: OrgId): Promise<AppliedState | null>;
61
- putAppliedState(state: AppliedState): Promise<void>;
85
+ /**
86
+ * With `fence` (#441), compare-and-set: the write happens only while
87
+ * `fence.token` is still the scope's current lease token, otherwise it
88
+ * throws {@link StaleLeaseError} and writes nothing.
89
+ */
90
+ putAppliedState(state: AppliedState, fence?: MutationLease): Promise<void>;
91
+ /**
92
+ * #441: takes the exclusive mutation lease for `scope` (`company:<orgId>`
93
+ * or `platform`), or returns {@link HeldLease} while another holder's lease
94
+ * has not expired. Every successful acquire issues a higher token than the last.
95
+ */
96
+ acquireMutationLease(scope: string, holder: string, ttlMs: number): Promise<MutationLease | HeldLease>;
97
+ /**
98
+ * Extends the lease to `ttlMs` from now, only while `lease.token` is still
99
+ * the scope's current token. Returns false once it is not: the holder lost
100
+ * the scope and must stop before its next provider step.
101
+ */
102
+ renewMutationLease(lease: MutationLease, ttlMs: number): Promise<boolean>;
103
+ /** Frees the lease at once. A no-op if the lease was already taken over. */
104
+ releaseMutationLease(lease: MutationLease): Promise<void>;
62
105
  /**
63
106
  * Removes the declared org and its applied state together. Called only by
64
107
  * `FoundryPlanApply.destroy` (`plan/plan-apply.ts`), once every resource it
@@ -68,7 +111,7 @@ export interface DeclaredOrgStore {
68
111
  * not an error, so a retried destroy that reaches this step twice never
69
112
  * throws.
70
113
  */
71
- delete(id: OrgId): Promise<void>;
114
+ delete(id: OrgId, fence?: MutationLease): Promise<void>;
72
115
  }
73
116
  /**
74
117
  * The persistence seam for #166's {@link DeclaredPlatform}, mirroring
@@ -87,8 +130,8 @@ export interface PlatformStore {
87
130
  * before the first apply. `put` never touches it.
88
131
  */
89
132
  getServiceIds(): Promise<Record<string, string>>;
90
- /** Replaces the recorded ids. A no-op until the platform is declared. */
91
- putServiceIds(serviceIds: Record<string, string>): Promise<void>;
133
+ /** Replaces the recorded ids. A no-op until the platform is declared. With `fence` (#441), compare-and-set as {@link DeclaredOrgStore.putAppliedState}. */
134
+ putServiceIds(serviceIds: Record<string, string>, fence?: MutationLease): Promise<void>;
92
135
  }
93
136
  /**
94
137
  * One recorded apply, appended to the history #253 keeps beside
package/dist/store.js CHANGED
@@ -1 +1,9 @@
1
- export {};
1
+ /** A fenced write (`putAppliedState`, `putServiceIds`) came from a lease that is no longer the current one (#441). Nothing was written. */
2
+ export class StaleLeaseError extends Error {
3
+ scope;
4
+ constructor(scope) {
5
+ super(`mutation lease for ${scope} was lost to a newer holder; nothing written`);
6
+ this.scope = scope;
7
+ this.name = 'StaleLeaseError';
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.16.16",
3
+ "version": "0.17.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",