@wtfalch/foundry 0.7.1 → 0.8.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.
- package/dist/bootstrap/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/versions.d.ts +8 -0
- package/dist/bootstrap/versions.js +31 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +1 -2
- package/dist/index.js +4 -1
- package/dist/migrations/0024_audit_events.sql +1 -1
- package/dist/migrations/0025_audit_display.sql +1 -1
- package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
- package/dist/migrations/0027_audit_chain.sql +1 -1
- package/dist/migrations/0028_audit_rls.sql +2 -2
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/plan/calls-matrix.d.ts +2 -0
- package/dist/plan/calls-matrix.js +2 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +76 -14
- package/dist/plan/executor.d.ts +305 -16
- package/dist/plan/executor.js +606 -9
- package/dist/plan/login-host.d.ts +24 -0
- package/dist/plan/login-host.js +21 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +20 -1
- package/dist/plan/platform-diff.d.ts +79 -1
- package/dist/plan/platform-diff.js +197 -1
- package/dist/plan/platform-providers.d.ts +36 -1
- package/dist/plan/platform-providers.js +153 -21
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +13 -17
- package/dist/store/audit.js +14 -18
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +13 -8
- package/package.json +6 -3
- package/versions.json +1 -1
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
|
-
import { VERSIONS } from '../bootstrap/versions.js';
|
|
3
|
+
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
4
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
5
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
6
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
7
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
8
8
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
9
9
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
10
10
|
function apexZoneKey(apex) {
|
|
@@ -93,6 +93,10 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
|
93
93
|
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
94
94
|
* ({@link provisionPlatformArchon}), once a host passes
|
|
95
95
|
* {@link PlatformArchonDeps}.
|
|
96
|
+
*
|
|
97
|
+
* #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
|
|
98
|
+
* platform database ({@link provisionPlatformBackup}), once a host passes
|
|
99
|
+
* {@link PlatformBackupDeps}.
|
|
96
100
|
*/
|
|
97
101
|
export class FoundryPlatformStepExecutor {
|
|
98
102
|
cloudflare;
|
|
@@ -106,6 +110,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
106
110
|
files;
|
|
107
111
|
ai;
|
|
108
112
|
archon;
|
|
113
|
+
backup;
|
|
109
114
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
110
115
|
this.cloudflare = cloudflare;
|
|
111
116
|
this.declaredOrgStore = declaredOrgStore;
|
|
@@ -118,6 +123,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
118
123
|
this.files = options.files;
|
|
119
124
|
this.ai = options.ai;
|
|
120
125
|
this.archon = options.archon;
|
|
126
|
+
this.backup = options.backup;
|
|
121
127
|
}
|
|
122
128
|
async blockedSteps(platform, recordedIds = {}) {
|
|
123
129
|
const blocked = {};
|
|
@@ -131,8 +137,19 @@ export class FoundryPlatformStepExecutor {
|
|
|
131
137
|
}
|
|
132
138
|
else if (platform.auth.enabled && this.auth) {
|
|
133
139
|
const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
|
|
134
|
-
if (authBlocked)
|
|
140
|
+
if (authBlocked) {
|
|
135
141
|
blocked.auth = authBlocked;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// #248: the same "block, not throw" rule mail's own tag check
|
|
145
|
+
// follows. `VERSIONS.zitadel` is a real pin today, so this never
|
|
146
|
+
// fires in practice -- it is here so a future bump that lands a
|
|
147
|
+
// floating tag is caught the same way mail's would be.
|
|
148
|
+
const zitadelTagBlocked = floatingTagBlocked('ZITADEL', VERSIONS.zitadel, 'v4.17.3');
|
|
149
|
+
if (zitadelTagBlocked) {
|
|
150
|
+
blocked.auth = `the platform's sign-in is blocked, not planned: ${zitadelTagBlocked}`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
136
153
|
// #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
|
|
137
154
|
if (otherInputsMissing.length > 0)
|
|
138
155
|
blocked.authOtherInputsMissing = otherInputsMissing;
|
|
@@ -198,6 +215,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
198
215
|
}
|
|
199
216
|
}
|
|
200
217
|
}
|
|
218
|
+
// Only once a database actually exists to back up -- the same
|
|
219
|
+
// "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
|
|
220
|
+
// itself follows for "no database", so an otherwise-unrelated platform
|
|
221
|
+
// with no backup deps wired carries no standing blocked note.
|
|
222
|
+
if (!this.backup &&
|
|
223
|
+
PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
|
|
224
|
+
blocked.backupExecutorMissing = true;
|
|
225
|
+
}
|
|
201
226
|
return blocked;
|
|
202
227
|
}
|
|
203
228
|
/**
|
|
@@ -271,6 +296,12 @@ export class FoundryPlatformStepExecutor {
|
|
|
271
296
|
if (action.system === 'coolify' && action.kind === 'jobs') {
|
|
272
297
|
return this.coolifyJobs(action, context);
|
|
273
298
|
}
|
|
299
|
+
if (action.system === 'coolify' && action.kind === 'backup') {
|
|
300
|
+
if (!this.backup) {
|
|
301
|
+
throw new Error(`platform:backup:${action.key} needs FoundryPlatformStepExecutor to be constructed with its backup dependencies (#257)`);
|
|
302
|
+
}
|
|
303
|
+
return provisionPlatformBackup(action, context, this.backup);
|
|
304
|
+
}
|
|
274
305
|
if (action.system === 'sfu' && action.kind === 'livekit') {
|
|
275
306
|
return this.executeSfu();
|
|
276
307
|
}
|
|
@@ -809,6 +840,15 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
809
840
|
await deps.coolify.updateApplication(zitadelAppId, {
|
|
810
841
|
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
811
842
|
});
|
|
843
|
+
// #251: only on create, never unconditionally -- nothing here patches
|
|
844
|
+
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
845
|
+
// already running, so that is the only moment this tag is actually live.
|
|
846
|
+
// Recording it every run, the way the app id above is, would make
|
|
847
|
+
// `versionNotAppliedLine` compare a bumped `VERSIONS.zitadel` against a
|
|
848
|
+
// value that was never applied to the running app.
|
|
849
|
+
if (zitadelApp.justCreated) {
|
|
850
|
+
await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
|
|
851
|
+
}
|
|
812
852
|
// ---- the zitadel-login app -----------------------------------------------
|
|
813
853
|
const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
|
|
814
854
|
const server = await resolvePlatformServer(deps.coolify);
|
|
@@ -1520,12 +1560,17 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1520
1560
|
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
1521
1561
|
return { resourceId: appId };
|
|
1522
1562
|
}
|
|
1523
|
-
/**
|
|
1563
|
+
/**
|
|
1564
|
+
* Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
|
|
1565
|
+
* over the estate-wide {@link floatingTagBlocked} -- the mail step's own
|
|
1566
|
+
* message, naming `gitRef` since that is the declared field this tag
|
|
1567
|
+
* actually comes from.
|
|
1568
|
+
*/
|
|
1524
1569
|
function mailImageTagBlocked(tag) {
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1570
|
+
const reason = floatingTagBlocked('Stalwart', tag, 'v0.16.24');
|
|
1571
|
+
return reason
|
|
1572
|
+
? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
|
|
1573
|
+
: null;
|
|
1529
1574
|
}
|
|
1530
1575
|
/** Why the server's address cannot be the MX A record's content, or `null`. */
|
|
1531
1576
|
function mailServerIpBlocked(ip) {
|
|
@@ -1933,7 +1978,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1933
1978
|
// response was lost, or one made for another hostname: its client id
|
|
1934
1979
|
// cannot be read back (`listApps` does not carry it), so it is deleted and
|
|
1935
1980
|
// made again, as `zitadelApp` does.
|
|
1936
|
-
const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
1981
|
+
const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
1937
1982
|
a.appType === 'web' &&
|
|
1938
1983
|
a.state !== 'APP_STATE_REMOVED');
|
|
1939
1984
|
if (orphan) {
|
|
@@ -3836,7 +3881,7 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
3836
3881
|
// An app by this name here has no recorded secret (no undelivered id, or
|
|
3837
3882
|
// the throw above would have fired): a crash between the create and the
|
|
3838
3883
|
// record. Nothing was delivered, so it is deleted and made again.
|
|
3839
|
-
const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3884
|
+
const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3840
3885
|
for (const app of stale) {
|
|
3841
3886
|
try {
|
|
3842
3887
|
await zitadel.deleteApp(org.id, project.id, app.id);
|
|
@@ -4339,6 +4384,61 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
4339
4384
|
await sleep(interval);
|
|
4340
4385
|
}
|
|
4341
4386
|
}
|
|
4387
|
+
/** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
|
|
4388
|
+
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
4389
|
+
/** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
|
|
4390
|
+
const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
4391
|
+
/** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
|
|
4392
|
+
const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
|
|
4393
|
+
/**
|
|
4394
|
+
* #257: schedules a nightly R2 backup of one platform database --
|
|
4395
|
+
* `planBackups` (`platform-diff.ts`) plans this step once per service, each
|
|
4396
|
+
* carrying that service's own database id as `action.desired.databaseId`
|
|
4397
|
+
* (recorded by that service's own `coolify:app` step; never minted here).
|
|
4398
|
+
* The R2 bucket is registered as a Coolify S3 storage once, by name, and
|
|
4399
|
+
* its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
|
|
4400
|
+
* every service's own backup step -- and a rerun of this one -- reuses it
|
|
4401
|
+
* rather than registering a second storage entry under the same name.
|
|
4402
|
+
* `createDatabaseBackup` itself is the idempotency boundary for the
|
|
4403
|
+
* schedule: an existing enabled, `save_s3` schedule is left alone rather
|
|
4404
|
+
* than replaced, since Coolify has no "upsert" for this call and a second
|
|
4405
|
+
* schedule would dump the same database twice a night.
|
|
4406
|
+
*/
|
|
4407
|
+
async function provisionPlatformBackup(action, context, deps) {
|
|
4408
|
+
const { coolify, r2 } = deps;
|
|
4409
|
+
const databaseId = action.desired.databaseId;
|
|
4410
|
+
const recorded = context.recordedIds();
|
|
4411
|
+
let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
|
|
4412
|
+
if (!storageUuid) {
|
|
4413
|
+
const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
|
|
4414
|
+
if (found) {
|
|
4415
|
+
storageUuid = found.uuid;
|
|
4416
|
+
}
|
|
4417
|
+
else {
|
|
4418
|
+
const created = await coolify.createS3Storage({
|
|
4419
|
+
name: PLATFORM_BACKUP_S3_STORAGE_NAME,
|
|
4420
|
+
endpoint: r2.endpoint,
|
|
4421
|
+
bucket: r2.bucket,
|
|
4422
|
+
region: r2.region ?? 'auto',
|
|
4423
|
+
key: r2.accessKeyId,
|
|
4424
|
+
secret: r2.secretAccessKey,
|
|
4425
|
+
is_usable: true,
|
|
4426
|
+
});
|
|
4427
|
+
storageUuid = created.uuid;
|
|
4428
|
+
}
|
|
4429
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
|
|
4430
|
+
}
|
|
4431
|
+
const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
|
|
4432
|
+
if (!existing) {
|
|
4433
|
+
await coolify.createDatabaseBackup(databaseId, {
|
|
4434
|
+
frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
4435
|
+
enabled: true,
|
|
4436
|
+
save_s3: true,
|
|
4437
|
+
s3_storage_uuid: storageUuid,
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
return undefined;
|
|
4441
|
+
}
|
|
4342
4442
|
/**
|
|
4343
4443
|
* The real {@link PlatformCloudflareReader}: the platform's apex zone as
|
|
4344
4444
|
* `planApexZone` expects to see it, and nothing else. The zone's DNS records
|
|
@@ -4393,23 +4493,55 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
4393
4493
|
this.platformStore = platformStore;
|
|
4394
4494
|
}
|
|
4395
4495
|
async listResources() {
|
|
4396
|
-
const
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
{
|
|
4496
|
+
const recorded = await this.platformStore.getServiceIds();
|
|
4497
|
+
const resources = [];
|
|
4498
|
+
const recordedAppId = recorded[JOBS_APP_KEY];
|
|
4499
|
+
if (recordedAppId) {
|
|
4500
|
+
try {
|
|
4501
|
+
const app = await this.client.getApplication(recordedAppId);
|
|
4502
|
+
resources.push({
|
|
4403
4503
|
system: 'coolify',
|
|
4404
4504
|
kind: 'jobs',
|
|
4405
4505
|
key: JOBS_SCHEDULER_KEY,
|
|
4406
4506
|
config: { uuid: app.uuid, status: app.status },
|
|
4407
|
-
}
|
|
4408
|
-
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
catch {
|
|
4510
|
+
// A recorded id Coolify no longer has reads as not-live (see this
|
|
4511
|
+
// class's own doc comment); the scheduler, not every other resource
|
|
4512
|
+
// this reader checks.
|
|
4513
|
+
}
|
|
4409
4514
|
}
|
|
4410
|
-
|
|
4411
|
-
|
|
4515
|
+
// #257: one backup-schedule resource per service whose database is
|
|
4516
|
+
// recorded, found the only way a schedule can be: listed off that
|
|
4517
|
+
// database itself, since Coolify names no schedule of its own.
|
|
4518
|
+
for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
|
|
4519
|
+
const databaseId = recorded[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
|
|
4520
|
+
if (!databaseId)
|
|
4521
|
+
continue;
|
|
4522
|
+
try {
|
|
4523
|
+
const backups = await this.client.listDatabaseBackups(databaseId);
|
|
4524
|
+
const scheduled = backups.find((backup) => backup.enabled && backup.save_s3);
|
|
4525
|
+
if (scheduled) {
|
|
4526
|
+
resources.push({
|
|
4527
|
+
system: 'coolify',
|
|
4528
|
+
kind: 'backup',
|
|
4529
|
+
key: service,
|
|
4530
|
+
config: { uuid: scheduled.uuid, frequency: scheduled.frequency },
|
|
4531
|
+
});
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
catch {
|
|
4535
|
+
// A Coolify error (or a recorded database id Coolify no longer has)
|
|
4536
|
+
// reads as not-live, the same as the jobs app above -- `databaseId`
|
|
4537
|
+
// is still recorded, so `planBackups` plans the step again rather
|
|
4538
|
+
// than reporting it blocked; `provisionPlatformBackup`'s own
|
|
4539
|
+
// `listDatabaseBackups` check (run again there) is what keeps a
|
|
4540
|
+
// transient failure here from scheduling a second backup once the
|
|
4541
|
+
// first one did succeed.
|
|
4542
|
+
}
|
|
4412
4543
|
}
|
|
4544
|
+
return resources;
|
|
4413
4545
|
}
|
|
4414
4546
|
}
|
|
4415
4547
|
/**
|
package/dist/plan.d.ts
CHANGED
|
@@ -53,4 +53,47 @@ export interface Plan {
|
|
|
53
53
|
orgId: OrgId;
|
|
54
54
|
steps: PlanStep[];
|
|
55
55
|
unmanaged: UnmanagedItem[];
|
|
56
|
+
/**
|
|
57
|
+
* #251: see {@link VersionChangeItem}. Optional so a `Plan` built before
|
|
58
|
+
* this field existed, or a test fixture that predates it, still satisfies
|
|
59
|
+
* this type with none.
|
|
60
|
+
*/
|
|
61
|
+
versionChanges?: VersionChangeItem[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The action kind a {@link VersionChangeItem} carries -- a distinct, typed
|
|
65
|
+
* value so Archon can key off it to render and later gate these lines
|
|
66
|
+
* (wtfalch/archon#133), and so it is never mistaken for a `StepAction.kind`
|
|
67
|
+
* (`plan/diff.ts`) any `StepExecutor` dispatches on.
|
|
68
|
+
*
|
|
69
|
+
* #251 round 3: an earlier attempt planned these as ordinary `PlanStep`s in
|
|
70
|
+
* `Plan.steps`. That made a plan holding only version-change lines for
|
|
71
|
+
* zitadel/synapse/mas (whose own step never re-applies a running app's
|
|
72
|
+
* image tag -- see {@link VersionChangeItem}) never read as empty, which is
|
|
73
|
+
* the condition `bootstrap/drive.ts` loops on, and `apply` tried to run a
|
|
74
|
+
* step for those services that does nothing. Version-change lines are
|
|
75
|
+
* carried on their own `Plan.versionChanges` field instead, never on
|
|
76
|
+
* `steps`, so they are display-only everywhere a `Plan` is read.
|
|
77
|
+
*/
|
|
78
|
+
export declare const VERSION_CHANGE_KIND = "version-change";
|
|
79
|
+
/**
|
|
80
|
+
* One informational line reporting a service whose version has drifted --
|
|
81
|
+
* either from what the next apply would actually put live (mail, archon,
|
|
82
|
+
* ai, files, integrations: each already redeploys when its own recorded
|
|
83
|
+
* applied ref differs from the declared one), or, for a service whose own
|
|
84
|
+
* step never patches a running app's image tag once created (zitadel,
|
|
85
|
+
* synapse, mas), from what is actually live. #251: the data to show this
|
|
86
|
+
* already existed per-service with no line anywhere saying a foundry
|
|
87
|
+
* release bumping a pinned version, or an operator changing a declared
|
|
88
|
+
* `gitRef`, had changed what the next apply would do -- or, for the second
|
|
89
|
+
* group, that it would do nothing at all.
|
|
90
|
+
*
|
|
91
|
+
* Never a {@link PlanStep}: it creates or changes nothing live, and is never
|
|
92
|
+
* passed to a `StepExecutor`.
|
|
93
|
+
*/
|
|
94
|
+
export interface VersionChangeItem {
|
|
95
|
+
kind: typeof VERSION_CHANGE_KIND;
|
|
96
|
+
/** The service this line is about, e.g. 'mail', 'archon', 'zitadel'. */
|
|
97
|
+
service: string;
|
|
98
|
+
description: string;
|
|
56
99
|
}
|
package/dist/plan.js
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The action kind a {@link VersionChangeItem} carries -- a distinct, typed
|
|
3
|
+
* value so Archon can key off it to render and later gate these lines
|
|
4
|
+
* (wtfalch/archon#133), and so it is never mistaken for a `StepAction.kind`
|
|
5
|
+
* (`plan/diff.ts`) any `StepExecutor` dispatches on.
|
|
6
|
+
*
|
|
7
|
+
* #251 round 3: an earlier attempt planned these as ordinary `PlanStep`s in
|
|
8
|
+
* `Plan.steps`. That made a plan holding only version-change lines for
|
|
9
|
+
* zitadel/synapse/mas (whose own step never re-applies a running app's
|
|
10
|
+
* image tag -- see {@link VersionChangeItem}) never read as empty, which is
|
|
11
|
+
* the condition `bootstrap/drive.ts` loops on, and `apply` tried to run a
|
|
12
|
+
* step for those services that does nothing. Version-change lines are
|
|
13
|
+
* carried on their own `Plan.versionChanges` field instead, never on
|
|
14
|
+
* `steps`, so they are display-only everywhere a `Plan` is read.
|
|
15
|
+
*/
|
|
16
|
+
export const VERSION_CHANGE_KIND = 'version-change';
|
package/dist/store/audit.d.ts
CHANGED
|
@@ -21,23 +21,19 @@ import type { FoundryStoreDb } from './postgres.js';
|
|
|
21
21
|
* backfill can still be written later, where discarding the old rows
|
|
22
22
|
* outright could not be undone.
|
|
23
23
|
*
|
|
24
|
-
* `checkRuntimeRole` defaults to `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* database level regardless (0001's triggers, 0004's hashing), only the
|
|
38
|
-
* extra fail-closed check on the *connection* is off. Options, not a second
|
|
39
|
-
* positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
|
|
40
|
-
* fixture -- can pass `true` once foundry's own database gets that split.
|
|
24
|
+
* `checkRuntimeRole` defaults to `true`, `createLedger`'s own default. The
|
|
25
|
+
* connection must be a restricted role: not a superuser, no BYPASSRLS, and no
|
|
26
|
+
* UPDATE, DELETE or TRUNCATE on `audit_events`. A call through the owner
|
|
27
|
+
* connection throws `UnsafeRuntimeRoleError`. The check reads attributes of
|
|
28
|
+
* the connected role, not its name.
|
|
29
|
+
*
|
|
30
|
+
* The host provides that role. After it migrates with the owner URL, it calls
|
|
31
|
+
* `ensureRuntimeRole` from `@wtfalch/db/runtime-role` with
|
|
32
|
+
* `appendOnly: ['audit_events']` and the four audit functions in `grants`
|
|
33
|
+
* (README, "Foundry's own Postgres"), then connects the server as the runtime
|
|
34
|
+
* role. A test fixture without that split (PGlite's one user is a superuser)
|
|
35
|
+
* passes `checkRuntimeRole: false`. Options, not a second positional argument
|
|
36
|
+
* (`CLAUDE.md` rule 7).
|
|
41
37
|
*/
|
|
42
38
|
export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
|
|
43
39
|
checkRuntimeRole?: boolean;
|
package/dist/store/audit.js
CHANGED
|
@@ -61,29 +61,25 @@ function boundPlan(plan) {
|
|
|
61
61
|
* backfill can still be written later, where discarding the old rows
|
|
62
62
|
* outright could not be undone.
|
|
63
63
|
*
|
|
64
|
-
* `checkRuntimeRole` defaults to `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* `
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* database level regardless (0001's triggers, 0004's hashing), only the
|
|
78
|
-
* extra fail-closed check on the *connection* is off. Options, not a second
|
|
79
|
-
* positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
|
|
80
|
-
* fixture -- can pass `true` once foundry's own database gets that split.
|
|
64
|
+
* `checkRuntimeRole` defaults to `true`, `createLedger`'s own default. The
|
|
65
|
+
* connection must be a restricted role: not a superuser, no BYPASSRLS, and no
|
|
66
|
+
* UPDATE, DELETE or TRUNCATE on `audit_events`. A call through the owner
|
|
67
|
+
* connection throws `UnsafeRuntimeRoleError`. The check reads attributes of
|
|
68
|
+
* the connected role, not its name.
|
|
69
|
+
*
|
|
70
|
+
* The host provides that role. After it migrates with the owner URL, it calls
|
|
71
|
+
* `ensureRuntimeRole` from `@wtfalch/db/runtime-role` with
|
|
72
|
+
* `appendOnly: ['audit_events']` and the four audit functions in `grants`
|
|
73
|
+
* (README, "Foundry's own Postgres"), then connects the server as the runtime
|
|
74
|
+
* role. A test fixture without that split (PGlite's one user is a superuser)
|
|
75
|
+
* passes `checkRuntimeRole: false`. Options, not a second positional argument
|
|
76
|
+
* (`CLAUDE.md` rule 7).
|
|
81
77
|
*/
|
|
82
78
|
export function createPostgresAuditLog(db, options = {}) {
|
|
83
79
|
const ledger = createLedger({
|
|
84
80
|
vocabulary: FOUNDRY_LEDGER_VOCABULARY,
|
|
85
81
|
hashChain: true,
|
|
86
|
-
checkRuntimeRole: options.checkRuntimeRole
|
|
82
|
+
checkRuntimeRole: options.checkRuntimeRole,
|
|
87
83
|
});
|
|
88
84
|
const targetFor = (orgId, target) => ({
|
|
89
85
|
type: target === 'platform' ? 'platform' : 'org',
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -7,6 +7,20 @@ import type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../s
|
|
|
7
7
|
* connection itself.
|
|
8
8
|
*/
|
|
9
9
|
export type FoundryStoreDb = PgDatabase<PgQueryResultHKT, any, any>;
|
|
10
|
+
/**
|
|
11
|
+
* The Postgres unique-violation constraint name, however the driver hands the
|
|
12
|
+
* error back -- directly (PGlite in this package's own tests) or wrapped in
|
|
13
|
+
* drizzle-orm's own `DrizzleQueryError`, whose `cause` carries the raw driver
|
|
14
|
+
* error (postgres-js in a host) -- or `undefined` when this is not a unique
|
|
15
|
+
* violation. Checked by Postgres error code, never by message text, since
|
|
16
|
+
* message text is not a contract. Same code-check pattern as
|
|
17
|
+
* `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
|
|
18
|
+
* here rather than shared, since neither module depends on the other; this
|
|
19
|
+
* one additionally reads the constraint name (`constraint` under node-postgres and PGlite, `constraint_name` under postgres-js) so `create`/`update` can tell a slug
|
|
20
|
+
* collision from a domain collision (#131) rather than reporting both the
|
|
21
|
+
* same way.
|
|
22
|
+
*/
|
|
23
|
+
export declare function uniqueViolationConstraint(error: unknown): string | undefined;
|
|
10
24
|
/**
|
|
11
25
|
* Thrown by `create` and `update` when the store refuses a write: an id
|
|
12
26
|
* that already exists, an id that does not, a slug that fails
|
package/dist/store/postgres.js
CHANGED
|
@@ -19,17 +19,22 @@ const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
|
|
|
19
19
|
* message text is not a contract. Same code-check pattern as
|
|
20
20
|
* `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
|
|
21
21
|
* here rather than shared, since neither module depends on the other; this
|
|
22
|
-
* one additionally reads `constraint` so `create`/`update` can tell a slug
|
|
22
|
+
* one additionally reads the constraint name (`constraint` under node-postgres and PGlite, `constraint_name` under postgres-js) so `create`/`update` can tell a slug
|
|
23
23
|
* collision from a domain collision (#131) rather than reporting both the
|
|
24
24
|
* same way.
|
|
25
25
|
*/
|
|
26
|
-
function uniqueViolationConstraint(error) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
export function uniqueViolationConstraint(error) {
|
|
27
|
+
// Walk `cause`: drizzle-orm wraps the driver error, and how deep differs by version. Bounded so a cyclic `cause` ends.
|
|
28
|
+
let current = error;
|
|
29
|
+
for (let depth = 0; depth < 5 && current !== null && typeof current === 'object'; depth += 1) {
|
|
30
|
+
const e = current;
|
|
31
|
+
if (e.code === UNIQUE_VIOLATION) {
|
|
32
|
+
// node-postgres spells it `constraint`; postgres-js spells it `constraint_name`.
|
|
33
|
+
const name = e.constraint ?? e.constraint_name;
|
|
34
|
+
return typeof name === 'string' ? name : undefined;
|
|
35
|
+
}
|
|
36
|
+
current = e.cause;
|
|
37
|
+
}
|
|
33
38
|
return undefined;
|
|
34
39
|
}
|
|
35
40
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"default": "./dist/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./migrations-dir": {
|
|
19
|
+
"types": "./dist/migrations-dir.d.ts",
|
|
20
|
+
"default": "./dist/migrations-dir.js"
|
|
21
|
+
},
|
|
18
22
|
"./migrations/*.sql": "./dist/migrations/*.sql",
|
|
19
23
|
"./package.json": "./package.json"
|
|
20
24
|
},
|
|
@@ -30,7 +34,6 @@
|
|
|
30
34
|
"typecheck": "tsc --noEmit",
|
|
31
35
|
"test": "vitest run",
|
|
32
36
|
"db:provision": "node scripts/foundry-db-provision.mjs",
|
|
33
|
-
"db:migrate": "node scripts/foundry-db-migrate.mjs",
|
|
34
37
|
"calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
|
|
35
38
|
},
|
|
36
39
|
"peerDependencies": {
|
|
@@ -47,8 +50,8 @@
|
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"@electric-sql/pglite": "^0.5.8",
|
|
49
52
|
"@types/node": "^22",
|
|
53
|
+
"@wtfalch/db": "0.5.2",
|
|
50
54
|
"drizzle-orm": "0.45.2",
|
|
51
|
-
"postgres": "3.4.9",
|
|
52
55
|
"typescript": "^5.9.0",
|
|
53
56
|
"vitest": "^4.1.6"
|
|
54
57
|
}
|
package/versions.json
CHANGED
package/dist/migrate.d.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
|
|
2
|
-
export declare const MIGRATIONS_DIR: string;
|
|
3
|
-
/**
|
|
4
|
-
* The one capability `migrate` needs from a driver: run SQL text (one
|
|
5
|
-
* statement or several) and run a callback inside a transaction. Deliberately
|
|
6
|
-
* not a drizzle `PgDatabase` or a concrete driver type -- drizzle's own
|
|
7
|
-
* `.execute()` routes a multi-statement string through a single prepared
|
|
8
|
-
* query on PGlite, which rejects it, so a migration file's raw text has to
|
|
9
|
-
* reach the driver's own multi-statement path (`postgres`'s `.unsafe()`,
|
|
10
|
-
* PGlite's `.exec()`) instead. Same "narrow port, no concrete client type"
|
|
11
|
-
* shape `plan/executor.ts`'s `CoolifyPort` uses, for the same reason: a real
|
|
12
|
-
* driver satisfies this structurally, with a few lines of adapter at the
|
|
13
|
-
* call site (see `scripts/foundry-db-migrate.mjs` and `migrate.test.ts`).
|
|
14
|
-
*/
|
|
15
|
-
export interface MigrateSql {
|
|
16
|
-
/** Run SQL text and discard any result -- the tracking table's own DDL, and each migration file's contents. */
|
|
17
|
-
exec(text: string): Promise<void>;
|
|
18
|
-
/**
|
|
19
|
-
* Run one literal SELECT and return its rows. Only ever called here with
|
|
20
|
-
* fixed text this module builds itself (the tracking table's name, and a
|
|
21
|
-
* migration filename already checked against `/^\d{4}_.+\.sql$/`), so
|
|
22
|
-
* there is no external input to inject.
|
|
23
|
-
*/
|
|
24
|
-
query<T = Record<string, unknown>>(text: string): Promise<T[]>;
|
|
25
|
-
/** Run `fn` inside one transaction, handing it a `MigrateSql` scoped to it; rolled back if `fn` throws. */
|
|
26
|
-
transaction<T>(fn: (tx: MigrateSql) => Promise<T>): Promise<T>;
|
|
27
|
-
}
|
|
28
|
-
export interface MigrationResult {
|
|
29
|
-
/** Names of the migration files applied this run, in filename order -- empty when the database was already current. */
|
|
30
|
-
applied: string[];
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Applies every migration file in `dir` (default: this package's own
|
|
34
|
-
* `../migrations`) not yet recorded in `foundry_schema_migrations`, in
|
|
35
|
-
* filename order, one transaction per file. Safe to call repeatedly: a file
|
|
36
|
-
* already recorded is never re-read, let alone re-run.
|
|
37
|
-
*/
|
|
38
|
-
export declare function migrate(sql: MigrateSql, dir?: string): Promise<MigrationResult>;
|
package/dist/migrate.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
// #R2 (map gap: foundry's own Postgres, two commands): a tracked, idempotent
|
|
5
|
-
// migration runner. `./test/db.ts` and `./runner/test/db.ts` already read
|
|
6
|
-
// and sort this same directory to build one concatenated blob for a fresh
|
|
7
|
-
// PGlite -- fine for a test fixture that always starts empty, wrong for a
|
|
8
|
-
// real host that must apply only what a `foundry_schema_migrations` table
|
|
9
|
-
// does not yet list. `migrationFiles` below is that same discovery, reused
|
|
10
|
-
// rather than re-implemented, kept per-file instead of concatenated.
|
|
11
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
/** This package's own migrations -- `../migrations` from `src/`, `./migrations` once built to `dist/` (`package.json`'s `build` script copies the `.sql` files there alongside `migrate.js`). */
|
|
13
|
-
export const MIGRATIONS_DIR = join(here, 'migrations');
|
|
14
|
-
const TRACKING_TABLE = 'foundry_schema_migrations';
|
|
15
|
-
function migrationFiles(dir) {
|
|
16
|
-
return readdirSync(dir)
|
|
17
|
-
.filter((f) => /^\d{4}_.+\.sql$/.test(f))
|
|
18
|
-
.sort()
|
|
19
|
-
.map((name) => ({ name, path: join(dir, name) }));
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Applies every migration file in `dir` (default: this package's own
|
|
23
|
-
* `../migrations`) not yet recorded in `foundry_schema_migrations`, in
|
|
24
|
-
* filename order, one transaction per file. Safe to call repeatedly: a file
|
|
25
|
-
* already recorded is never re-read, let alone re-run.
|
|
26
|
-
*/
|
|
27
|
-
export async function migrate(sql, dir = MIGRATIONS_DIR) {
|
|
28
|
-
await sql.exec(`create table if not exists ${TRACKING_TABLE} (name text primary key, applied_at timestamptz not null default now())`);
|
|
29
|
-
const appliedRows = await sql.query(`select name from ${TRACKING_TABLE}`);
|
|
30
|
-
const applied = new Set(appliedRows.map((row) => row.name));
|
|
31
|
-
const pending = migrationFiles(dir).filter((file) => !applied.has(file.name));
|
|
32
|
-
const done = [];
|
|
33
|
-
for (const file of pending) {
|
|
34
|
-
const text = readFileSync(file.path, 'utf8');
|
|
35
|
-
await sql.transaction(async (tx) => {
|
|
36
|
-
await tx.exec(text);
|
|
37
|
-
// `file.name` is drawn from `migrationFiles`' own regex-filtered
|
|
38
|
-
// `readdirSync`, never from outside this module -- see `MigrateSql`'s
|
|
39
|
-
// doc comment. Quotes are still escaped, so an oddly-named future
|
|
40
|
-
// migration file cannot break the statement.
|
|
41
|
-
const escaped = file.name.replace(/'/g, "''");
|
|
42
|
-
await tx.exec(`insert into ${TRACKING_TABLE} (name) values ('${escaped}')`);
|
|
43
|
-
});
|
|
44
|
-
done.push(file.name);
|
|
45
|
-
}
|
|
46
|
-
return { applied: done };
|
|
47
|
-
}
|