@wtfalch/foundry 0.7.1 → 0.9.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 (54) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.js +1 -5
  4. package/dist/bootstrap/versions.d.ts +8 -0
  5. package/dist/bootstrap/versions.js +31 -0
  6. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  7. package/dist/calls-sfu-deploy-support.js +1 -1
  8. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  9. package/dist/calls-sfu-ssh-adapter.js +1 -1
  10. package/dist/calls-sfu.d.ts +5 -4
  11. package/dist/calls-sfu.js +5 -4
  12. package/dist/index.d.ts +4 -5
  13. package/dist/index.js +6 -3
  14. package/dist/migrations/0024_audit_events.sql +1 -1
  15. package/dist/migrations/0025_audit_display.sql +1 -1
  16. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  17. package/dist/migrations/0027_audit_chain.sql +1 -1
  18. package/dist/migrations/0028_audit_rls.sql +2 -2
  19. package/dist/migrations/0029_hosting_entries.sql +109 -0
  20. package/dist/migrations-dir.d.ts +9 -0
  21. package/dist/migrations-dir.js +10 -0
  22. package/dist/org.d.ts +59 -27
  23. package/dist/org.js +98 -26
  24. package/dist/plan/calls-matrix.d.ts +2 -0
  25. package/dist/plan/calls-matrix.js +2 -0
  26. package/dist/plan/diff.d.ts +3 -1
  27. package/dist/plan/diff.js +100 -32
  28. package/dist/plan/executor.d.ts +324 -19
  29. package/dist/plan/executor.js +764 -34
  30. package/dist/plan/index.d.ts +1 -1
  31. package/dist/plan/index.js +1 -1
  32. package/dist/plan/login-host.d.ts +24 -0
  33. package/dist/plan/login-host.js +21 -0
  34. package/dist/plan/plan-apply.d.ts +2 -0
  35. package/dist/plan/plan-apply.js +26 -10
  36. package/dist/plan/platform-diff.d.ts +102 -12
  37. package/dist/plan/platform-diff.js +236 -15
  38. package/dist/plan/platform-providers.d.ts +43 -1
  39. package/dist/plan/platform-providers.js +245 -33
  40. package/dist/plan/zitadel-system-api.d.ts +64 -0
  41. package/dist/plan/zitadel-system-api.js +125 -0
  42. package/dist/plan.d.ts +43 -0
  43. package/dist/plan.js +16 -1
  44. package/dist/platform.d.ts +1 -1
  45. package/dist/store/audit.d.ts +13 -17
  46. package/dist/store/audit.js +14 -18
  47. package/dist/store/postgres.d.ts +14 -0
  48. package/dist/store/postgres.js +15 -18
  49. package/dist/store/schema.d.ts +20 -1
  50. package/dist/store/schema.js +7 -1
  51. package/package.json +6 -3
  52. package/versions.json +1 -1
  53. package/dist/migrate.d.ts +0 -38
  54. package/dist/migrate.js +0 -47
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
- export {};
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';
@@ -4,7 +4,7 @@ import type { OrgId } from './org.js';
4
4
  * {@link DeclaredPlatform}. `enabled` turns the service's own provisioning
5
5
  * on; `hostname` is where it is served (default `<service>.<apex>`,
6
6
  * {@link defaultPlatformHostname}); `gitRef` is the release it runs, the
7
- * same "declared, not discovered" shape `HostingConfig.gitBranch` uses for
7
+ * same "declared, not discovered" shape `HostingEntry.branch` uses for
8
8
  * an org's own app (`org.ts`).
9
9
  */
10
10
  export interface PlatformService {
@@ -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 `false` here, the opposite of
25
- * `createLedger`'s own default (#232, needs a human call -- flagged
26
- * `ai:needs-human`, not decided here): that check requires the connection
27
- * to be a real, restricted `<database>_rt` role, the one 0001's migration
28
- * revokes UPDATE/DELETE/TRUNCATE from by name
29
- * (`current_database() || '_rt'`). foundry has no such role for its own
30
- * database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
31
- * split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
32
- * `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
33
- * database -- this is foundry's *own*). Leaving the default on would make
34
- * every real call throw `UnsafeRuntimeRoleError`, since the revoke in
35
- * 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
36
- * actually is; the append-only guard and the chain still hold at the
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;
@@ -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 `false` here, the opposite of
65
- * `createLedger`'s own default (#232, needs a human call -- flagged
66
- * `ai:needs-human`, not decided here): that check requires the connection
67
- * to be a real, restricted `<database>_rt` role, the one 0001's migration
68
- * revokes UPDATE/DELETE/TRUNCATE from by name
69
- * (`current_database() || '_rt'`). foundry has no such role for its own
70
- * database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
71
- * split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
72
- * `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
73
- * database -- this is foundry's *own*). Leaving the default on would make
74
- * every real call throw `UnsafeRuntimeRoleError`, since the revoke in
75
- * 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
76
- * actually is; the append-only guard and the chain still hold at the
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 ?? false,
82
+ checkRuntimeRole: options.checkRuntimeRole,
87
83
  });
88
84
  const targetFor = (orgId, target) => ({
89
85
  type: target === 'platform' ? 'platform' : 'org',
@@ -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
  /**
@@ -74,13 +79,9 @@ function rowToOrg(row) {
74
79
  hosting: {
75
80
  enabled: row.hostingEnabled,
76
81
  usesDomain: row.hostingUsesDomain,
77
- database: row.hostingDatabase,
78
82
  tunnel: row.hostingTunnel,
79
- gitBranch: row.hostingGitBranch,
80
- buildPack: row.hostingBuildPack,
81
83
  issuerApps: row.hostingIssuerApps,
82
- accessEmails: row.hostingAccessEmails,
83
- appEnv: row.hostingAppEnv,
84
+ entries: row.hostingEntries,
84
85
  },
85
86
  sso: { enabled: row.ssoEnabled },
86
87
  guests: { enabled: row.guestsEnabled },
@@ -117,13 +118,9 @@ function orgToRow(org) {
117
118
  siteUsesDomain: org.site.usesDomain,
118
119
  hostingEnabled: org.hosting.enabled,
119
120
  hostingUsesDomain: org.hosting.usesDomain,
120
- hostingDatabase: org.hosting.database,
121
121
  hostingTunnel: org.hosting.tunnel,
122
- hostingGitBranch: org.hosting.gitBranch,
123
- hostingBuildPack: org.hosting.buildPack,
124
122
  hostingIssuerApps: org.hosting.issuerApps,
125
- hostingAccessEmails: org.hosting.accessEmails,
126
- hostingAppEnv: org.hosting.appEnv ?? {},
123
+ hostingEntries: org.hosting.entries,
127
124
  ssoEnabled: org.sso.enabled,
128
125
  guestsEnabled: org.guests.enabled,
129
126
  soloEnabled: org.solo.enabled,
@@ -1,4 +1,4 @@
1
- import type { AppEnvSource, IssuerApp } from '../org.js';
1
+ import type { AppEnvSource, HostingEntry, IssuerApp } from '../org.js';
2
2
  import type { Plan } from '../plan.js';
3
3
  import type { Checkpoint } from '../state.js';
4
4
  /**
@@ -463,6 +463,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
463
463
  }, {}, {
464
464
  $type: Record<string, AppEnvSource>;
465
465
  }>;
466
+ hostingEntries: import("drizzle-orm/pg-core").PgColumn<{
467
+ name: "hosting_entries";
468
+ tableName: "foundry_declared_orgs";
469
+ dataType: "json";
470
+ columnType: "PgJsonb";
471
+ data: HostingEntry[];
472
+ driverParam: unknown;
473
+ notNull: true;
474
+ hasDefault: true;
475
+ isPrimaryKey: false;
476
+ isAutoincrement: false;
477
+ hasRuntimeDefault: false;
478
+ enumValues: undefined;
479
+ baseColumn: never;
480
+ identity: undefined;
481
+ generated: undefined;
482
+ }, {}, {
483
+ $type: HostingEntry[];
484
+ }>;
466
485
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
467
486
  name: "sso_enabled";
468
487
  tableName: "foundry_declared_orgs";
@@ -43,11 +43,17 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
43
43
  hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
44
44
  hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
45
45
  // HostingConfig.appEnv (org.ts, #243). Maps the app's own env var names to
46
- // values the plan already mints; empty by default.
46
+ // values the plan already mints; empty by default. Read only by migration
47
+ // 0029, which copies it into `hostingEntries`.
47
48
  hostingAppEnv: jsonb('hosting_app_env')
48
49
  .notNull()
49
50
  .default({})
50
51
  .$type(),
52
+ // HostingConfig.entries (org.ts): one entry per app. Replaces the flat
53
+ // hosting_database, hosting_git_branch, hosting_build_pack,
54
+ // hosting_access_emails and hosting_app_env columns, which stay for an older
55
+ // reader and are no longer written.
56
+ hostingEntries: jsonb('hosting_entries').notNull().default([]).$type(),
51
57
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
52
58
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
53
59
  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.7.1",
3
+ "version": "0.9.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
@@ -2,7 +2,7 @@
2
2
  "zitadel": "v4.17.3",
3
3
  "synapse": "v1.161.0",
4
4
  "mas": "1.24.0",
5
- "livekit": "latest",
5
+ "livekit": "v1.13.7",
6
6
  "cloudflared": "2026.9.3",
7
7
  "stalwart": "v0.16.24"
8
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
- }