@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.
- package/dist/bootstrap/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +25 -0
- package/dist/bootstrap/versions.js +43 -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 +8 -6
- package/dist/calls-sfu.js +9 -6
- package/dist/index.d.ts +3 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +4 -2
- package/dist/plan/calls-matrix.js +5 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +18 -3
- package/dist/plan/diff.js +369 -42
- package/dist/plan/executor.d.ts +378 -44
- package/dist/plan/executor.js +816 -57
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +88 -0
- package/dist/plan/login-host.js +73 -0
- package/dist/plan/plan-apply.d.ts +23 -8
- package/dist/plan/plan-apply.js +30 -10
- package/dist/plan/platform-diff.d.ts +102 -2
- package/dist/plan/platform-diff.js +232 -1
- package/dist/plan/platform-providers.d.ts +37 -2
- package/dist/plan/platform-providers.js +223 -36
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +36 -4
- package/dist/store/audit.js +110 -29
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +17 -9
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +10 -5
- package/versions.json +8 -0
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
package/dist/store/audit.js
CHANGED
|
@@ -1,44 +1,125 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createLedger, ledgerVocabulary } from '@wtfalch/audit';
|
|
2
2
|
/**
|
|
3
|
-
* foundry's own
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
orgId,
|
|
16
|
-
plan,
|
|
17
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
29
|
-
plan,
|
|
30
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
121
|
+
reason: boundReason(reason),
|
|
122
|
+
occurredAt: at,
|
|
42
123
|
});
|
|
43
124
|
},
|
|
44
125
|
};
|
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
|
/**
|
|
@@ -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,
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -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";
|
package/dist/store/schema.js
CHANGED
|
@@ -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.
|
|
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
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
|
-
}
|