@wtfalch/foundry 0.6.0 → 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.
Files changed (58) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +6 -2
  5. package/dist/bootstrap/versions.d.ts +25 -0
  6. package/dist/bootstrap/versions.js +43 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  8. package/dist/calls-sfu-deploy-support.js +1 -1
  9. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  10. package/dist/calls-sfu-ssh-adapter.js +1 -1
  11. package/dist/calls-sfu.d.ts +8 -6
  12. package/dist/calls-sfu.js +9 -6
  13. package/dist/index.d.ts +3 -4
  14. package/dist/index.js +5 -2
  15. package/dist/migrations/0022_chat_enabled.sql +6 -0
  16. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  17. package/dist/migrations/0024_audit_events.sql +211 -0
  18. package/dist/migrations/0025_audit_display.sql +147 -0
  19. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  20. package/dist/migrations/0027_audit_chain.sql +170 -0
  21. package/dist/migrations/0028_audit_rls.sql +111 -0
  22. package/dist/migrations-dir.d.ts +9 -0
  23. package/dist/migrations-dir.js +10 -0
  24. package/dist/org.d.ts +24 -0
  25. package/dist/org.js +13 -0
  26. package/dist/plan/agora.d.ts +21 -0
  27. package/dist/plan/agora.js +23 -0
  28. package/dist/plan/calls-matrix.d.ts +4 -2
  29. package/dist/plan/calls-matrix.js +5 -2
  30. package/dist/plan/cloudflared-connector.d.ts +3 -2
  31. package/dist/plan/cloudflared-connector.js +5 -3
  32. package/dist/plan/diff.d.ts +18 -3
  33. package/dist/plan/diff.js +369 -42
  34. package/dist/plan/executor.d.ts +378 -44
  35. package/dist/plan/executor.js +816 -57
  36. package/dist/plan/index.d.ts +2 -0
  37. package/dist/plan/index.js +1 -0
  38. package/dist/plan/login-host.d.ts +88 -0
  39. package/dist/plan/login-host.js +73 -0
  40. package/dist/plan/plan-apply.d.ts +23 -8
  41. package/dist/plan/plan-apply.js +30 -10
  42. package/dist/plan/platform-diff.d.ts +102 -2
  43. package/dist/plan/platform-diff.js +232 -1
  44. package/dist/plan/platform-providers.d.ts +37 -2
  45. package/dist/plan/platform-providers.js +223 -36
  46. package/dist/plan/providers-readers.js +44 -1
  47. package/dist/plan.d.ts +43 -0
  48. package/dist/plan.js +16 -1
  49. package/dist/store/audit.d.ts +36 -4
  50. package/dist/store/audit.js +110 -29
  51. package/dist/store/postgres.d.ts +14 -0
  52. package/dist/store/postgres.js +17 -9
  53. package/dist/store/schema.d.ts +37 -1
  54. package/dist/store/schema.js +7 -0
  55. package/package.json +10 -5
  56. package/versions.json +8 -0
  57. package/dist/migrate.d.ts +0 -38
  58. package/dist/migrate.js +0 -47
@@ -1,44 +1,125 @@
1
- import { foundryAuditEvents } from './schema.js';
1
+ import { createLedger, ledgerVocabulary } from '@wtfalch/audit';
2
2
  /**
3
- * foundry's own {@link AuditLog}, one row per plan, apply or destroy in
4
- * `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
5
- * attached archon reads the same table. It only inserts.
3
+ * foundry's own closed vocabulary (#232): one event per `AuditLog` method,
4
+ * `outcome` carrying how it went rather than the action name repeating it
5
+ * (the same split `@wtfalch/audit`'s own "service with no tenant database"
6
+ * example uses: `action: 'plan.applied'`, `outcome: 'refused'`). `actor`
7
+ * has one class, `'operator'`, because `AuditActor` (`plan-apply.ts`) does
8
+ * not distinguish a human from a service caller -- nothing here guesses
9
+ * one. `'recorded'` sorts first so it is the default outcome
10
+ * `plan.computed` rows get (`recordPlan` has no outcome of its own to pass).
6
11
  */
7
- export function createPostgresAuditLog(db) {
12
+ const FOUNDRY_LEDGER_VOCABULARY = ledgerVocabulary({
13
+ events: {
14
+ 'plan.computed': { tenantVisible: false },
15
+ 'apply.recorded': { tenantVisible: false },
16
+ 'destroy.recorded': { tenantVisible: false },
17
+ },
18
+ actorClasses: ['operator'],
19
+ contexts: ['operator'],
20
+ outcomes: ['recorded', 'applied', 'destroyed', 'refused', 'failed'],
21
+ });
22
+ /**
23
+ * The ledger table's CHECKs (migration 0024): reason <= 512 chars, `after`
24
+ * <= 65536 bytes as jsonb text, actor_display 1-256 chars. A write that
25
+ * breaks one throws and would hide the apply error being recorded, so each
26
+ * value is bounded here. Postgres' jsonb text adds a space after every `:`
27
+ * and `,`, so the plan is measured against half the limit.
28
+ */
29
+ const MAX_REASON = 512;
30
+ const MAX_AFTER_BYTES = 32768;
31
+ const MAX_DISPLAY = 256;
32
+ function boundReason(reason) {
33
+ return reason === undefined ? null : reason.slice(0, MAX_REASON);
34
+ }
35
+ function boundDisplay(actor) {
36
+ return (actor.display || actor.id).slice(0, MAX_DISPLAY);
37
+ }
38
+ function boundPlan(plan) {
39
+ return Buffer.byteLength(JSON.stringify(plan)) <= MAX_AFTER_BYTES
40
+ ? plan
41
+ : { truncated: true, steps: plan.steps.length, unmanaged: plan.unmanaged.length };
42
+ }
43
+ /**
44
+ * foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
45
+ * (#232) instead of the hand-rolled `foundry_audit_events` table
46
+ * (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
47
+ * (foundry has no tenant of its own -- the org a plan is *for* is the
48
+ * `target`, not a tenant), `hashChain` on so a changed or deleted row is
49
+ * detectable (`verifyChain`), through migrations 0024-0028
50
+ * (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
51
+ * `migrations/0001-0005`, the same files its own `audit-migrations` CLI
52
+ * would copy into a host that has a `drizzle/` journal -- foundry does not,
53
+ * so they are hand-placed at the next free numbers instead).
54
+ *
55
+ * The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
56
+ * so `bootstrap.ts` and `redactingAudit` need no change either.
57
+ *
58
+ * Decide (#232, open): whether `foundry_audit_events`' existing rows are
59
+ * copied into the ledger or left as a closed, separate trail. Left as-is
60
+ * here -- the simpler default, and the one that forecloses nothing: a
61
+ * backfill can still be written later, where discarding the old rows
62
+ * outright could not be undone.
63
+ *
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).
77
+ */
78
+ export function createPostgresAuditLog(db, options = {}) {
79
+ const ledger = createLedger({
80
+ vocabulary: FOUNDRY_LEDGER_VOCABULARY,
81
+ hashChain: true,
82
+ checkRuntimeRole: options.checkRuntimeRole,
83
+ });
84
+ const targetFor = (orgId, target) => ({
85
+ type: target === 'platform' ? 'platform' : 'org',
86
+ id: orgId,
87
+ });
8
88
  return {
9
89
  async recordPlan({ actor, orgId, plan, target, at }) {
10
- await db.insert(foundryAuditEvents).values({
11
- at,
12
- kind: 'plan',
13
- actorId: actor.id,
14
- actorDisplay: actor.display,
15
- orgId,
16
- plan,
17
- target: target ?? null,
90
+ await ledger.sign(db, {
91
+ action: 'plan.computed',
92
+ tenantId: null,
93
+ actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
94
+ context: 'operator',
95
+ target: targetFor(orgId, target),
96
+ after: boundPlan(plan),
97
+ occurredAt: at,
18
98
  });
19
99
  },
20
100
  async recordApply({ actor, orgId, plan, outcome, reason, target, at }) {
21
- await db.insert(foundryAuditEvents).values({
22
- at,
23
- kind: 'apply',
24
- actorId: actor.id,
25
- actorDisplay: actor.display,
26
- orgId,
101
+ await ledger.sign(db, {
102
+ action: 'apply.recorded',
103
+ tenantId: null,
104
+ actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
105
+ context: 'operator',
106
+ target: targetFor(orgId, target),
27
107
  outcome,
28
- reason: reason ?? null,
29
- plan,
30
- target: target ?? null,
108
+ reason: boundReason(reason),
109
+ after: boundPlan(plan),
110
+ occurredAt: at,
31
111
  });
32
112
  },
33
113
  async recordDestroy({ actor, orgId, outcome, reason, at }) {
34
- await db.insert(foundryAuditEvents).values({
35
- at,
36
- kind: 'destroy',
37
- actorId: actor.id,
38
- actorDisplay: actor.display,
39
- orgId,
114
+ await ledger.sign(db, {
115
+ action: 'destroy.recorded',
116
+ tenantId: null,
117
+ actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
118
+ context: 'operator',
119
+ target: targetFor(orgId),
40
120
  outcome,
41
- reason: reason ?? null,
121
+ reason: boundReason(reason),
122
+ occurredAt: at,
42
123
  });
43
124
  },
44
125
  };
@@ -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
@@ -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
- const direct = error;
28
- if (direct?.code === UNIQUE_VIOLATION)
29
- return direct.constraint;
30
- const cause = error?.cause;
31
- if (cause?.code === UNIQUE_VIOLATION)
32
- return cause.constraint;
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
  /**
@@ -67,7 +72,7 @@ function rowToOrg(row) {
67
72
  mailApps: row.mailApps,
68
73
  roleMailboxes: row.roleMailboxes,
69
74
  },
70
- chat: { usesDomain: row.chatUsesDomain },
75
+ chat: { enabled: row.chatEnabled, usesDomain: row.chatUsesDomain },
71
76
  calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
72
77
  issuer: { usesDomain: row.issuerUsesDomain },
73
78
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
@@ -80,6 +85,7 @@ function rowToOrg(row) {
80
85
  buildPack: row.hostingBuildPack,
81
86
  issuerApps: row.hostingIssuerApps,
82
87
  accessEmails: row.hostingAccessEmails,
88
+ appEnv: row.hostingAppEnv,
83
89
  },
84
90
  sso: { enabled: row.ssoEnabled },
85
91
  guests: { enabled: row.guestsEnabled },
@@ -107,6 +113,7 @@ function orgToRow(org) {
107
113
  noReplyLocalPart: org.mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART,
108
114
  mailApps: org.mail.mailApps ?? [...DEFAULT_MAIL_APPS],
109
115
  roleMailboxes: org.mail.roleMailboxes ?? [],
116
+ chatEnabled: org.chat.enabled,
110
117
  chatUsesDomain: org.chat.usesDomain,
111
118
  callsEnabled: org.calls.enabled,
112
119
  callsUsesDomain: org.calls.usesDomain,
@@ -121,6 +128,7 @@ function orgToRow(org) {
121
128
  hostingBuildPack: org.hosting.buildPack,
122
129
  hostingIssuerApps: org.hosting.issuerApps,
123
130
  hostingAccessEmails: org.hosting.accessEmails,
131
+ hostingAppEnv: org.hosting.appEnv ?? {},
124
132
  ssoEnabled: org.sso.enabled,
125
133
  guestsEnabled: org.guests.enabled,
126
134
  soloEnabled: org.solo.enabled,
@@ -1,4 +1,4 @@
1
- import type { IssuerApp } from '../org.js';
1
+ import type { AppEnvSource, IssuerApp } from '../org.js';
2
2
  import type { Plan } from '../plan.js';
3
3
  import type { Checkpoint } from '../state.js';
4
4
  /**
@@ -185,6 +185,23 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
185
185
  }, {}, {
186
186
  $type: string[];
187
187
  }>;
188
+ chatEnabled: import("drizzle-orm/pg-core").PgColumn<{
189
+ name: "chat_enabled";
190
+ tableName: "foundry_declared_orgs";
191
+ dataType: "boolean";
192
+ columnType: "PgBoolean";
193
+ data: boolean;
194
+ driverParam: boolean;
195
+ notNull: true;
196
+ hasDefault: true;
197
+ isPrimaryKey: false;
198
+ isAutoincrement: false;
199
+ hasRuntimeDefault: false;
200
+ enumValues: undefined;
201
+ baseColumn: never;
202
+ identity: undefined;
203
+ generated: undefined;
204
+ }, {}, {}>;
188
205
  chatUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
189
206
  name: "chat_uses_domain";
190
207
  tableName: "foundry_declared_orgs";
@@ -427,6 +444,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
427
444
  }, {}, {
428
445
  $type: string[];
429
446
  }>;
447
+ hostingAppEnv: import("drizzle-orm/pg-core").PgColumn<{
448
+ name: "hosting_app_env";
449
+ tableName: "foundry_declared_orgs";
450
+ dataType: "json";
451
+ columnType: "PgJsonb";
452
+ data: Record<string, AppEnvSource>;
453
+ driverParam: unknown;
454
+ notNull: true;
455
+ hasDefault: true;
456
+ isPrimaryKey: false;
457
+ isAutoincrement: false;
458
+ hasRuntimeDefault: false;
459
+ enumValues: undefined;
460
+ baseColumn: never;
461
+ identity: undefined;
462
+ generated: undefined;
463
+ }, {}, {
464
+ $type: Record<string, AppEnvSource>;
465
+ }>;
430
466
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
431
467
  name: "sso_enabled";
432
468
  tableName: "foundry_declared_orgs";
@@ -25,6 +25,7 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
25
25
  // MailConfig.roleMailboxes (org.ts, #230). Local parts of the role mailboxes
26
26
  // pre-created on the org's mail domain; empty by default.
27
27
  roleMailboxes: jsonb('role_mailboxes').notNull().default([]).$type(),
28
+ chatEnabled: boolean('chat_enabled').notNull().default(false),
28
29
  chatUsesDomain: boolean('chat_uses_domain').notNull().default(false),
29
30
  callsEnabled: boolean('calls_enabled').notNull().default(false),
30
31
  callsUsesDomain: boolean('calls_uses_domain').notNull().default(false),
@@ -41,6 +42,12 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
41
42
  hostingBuildPack: text('hosting_build_pack').notNull().default('nixpacks'),
42
43
  hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
43
44
  hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
45
+ // HostingConfig.appEnv (org.ts, #243). Maps the app's own env var names to
46
+ // values the plan already mints; empty by default.
47
+ hostingAppEnv: jsonb('hosting_app_env')
48
+ .notNull()
49
+ .default({})
50
+ .$type(),
44
51
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
45
52
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
46
53
  soloEnabled: boolean('solo_enabled').notNull().default(false),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.6.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",
@@ -9,12 +9,16 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "type": "module",
12
- "files": ["dist"],
12
+ "files": ["dist", "versions.json"],
13
13
  "exports": {
14
14
  ".": {
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,23 +34,24 @@
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": {
37
40
  "drizzle-orm": ">=0.45.2"
38
41
  },
39
42
  "dependencies": {
43
+ "@wtfalch/audit": "0.6.0",
40
44
  "@wtfalch/auth": "0.16.0",
41
45
  "@wtfalch/email": "0.15.0",
42
46
  "@wtfalch/providers": "0.6.0",
43
- "ssh2": "1.17.0"
47
+ "ssh2": "1.17.0",
48
+ "zod": "4.6.5"
44
49
  },
45
50
  "devDependencies": {
46
51
  "@electric-sql/pglite": "^0.5.8",
47
52
  "@types/node": "^22",
53
+ "@wtfalch/db": "0.5.2",
48
54
  "drizzle-orm": "0.45.2",
49
- "postgres": "3.4.9",
50
55
  "typescript": "^5.9.0",
51
56
  "vitest": "^4.1.6"
52
57
  }
package/versions.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "zitadel": "v4.17.3",
3
+ "synapse": "v1.161.0",
4
+ "mas": "1.24.0",
5
+ "livekit": "v1.13.7",
6
+ "cloudflared": "2026.9.3",
7
+ "stalwart": "v0.16.24"
8
+ }
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
- }