@plakboek/db 0.1.1 → 0.2.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/README.md +10 -9
- package/dist/index.js +138 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@ for Plakboek CMS installations.
|
|
|
8
8
|
- `createDb` -- a Drizzle (`drizzle-orm/postgres-js`) connection factory
|
|
9
9
|
over the `postgres` (porsager) driver.
|
|
10
10
|
- `runMigrations` -- applies this package's own statically registered
|
|
11
|
-
migrations,
|
|
12
|
-
concurrent migrators can never double-apply (D-03). What
|
|
13
|
-
step invokes.
|
|
14
|
-
- `
|
|
15
|
-
|
|
11
|
+
migrations, taking a session-scoped Postgres advisory lock internally for
|
|
12
|
+
the whole run so concurrent migrators can never double-apply (D-03). What
|
|
13
|
+
Phase 6's deploy step invokes.
|
|
14
|
+
- `MigrationLockTimeoutError` -- thrown by `runMigrations` when another
|
|
15
|
+
migrator still holds that advisory lock after `lockWaitMs`.
|
|
16
16
|
- The migration authoring convention every migration -- this package's own
|
|
17
17
|
and every host/host-extension migration -- follows: see
|
|
18
18
|
[`MIGRATIONS.md`](./MIGRATIONS.md).
|
|
@@ -67,10 +67,11 @@ order. A second overlapping call either observes the first call's
|
|
|
67
67
|
migrations as already applied, or throws `MigrationLockTimeoutError` if it
|
|
68
68
|
gives up waiting for the lock before the first call finishes.
|
|
69
69
|
|
|
70
|
-
`MIGRATIONS`
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
`MIGRATIONS` starts with `0001_auth_core`, which creates the authentication
|
|
71
|
+
tables (`"user"`, `session`, `account`, `verification`, `two_factor`) and
|
|
72
|
+
the append-only `audit_log`. See [`MIGRATIONS.md`](./MIGRATIONS.md) for how
|
|
73
|
+
later migrations are added, the transactional-vs-existence-guarded-statement
|
|
74
|
+
decision, and what each thrown error class means.
|
|
74
75
|
|
|
75
76
|
## Errors
|
|
76
77
|
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,15 @@ function createDb(options) {
|
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/lock.ts
|
|
37
|
+
/**
|
|
38
|
+
* Fixed advisory-lock key reserved for @plakboek/db's migration runner.
|
|
39
|
+
* Postgres advisory locks share a single namespace per database across every
|
|
40
|
+
* caller -- this key must never be reused by another subsystem within a
|
|
41
|
+
* @plakboek/db-managed database.
|
|
42
|
+
*/
|
|
43
|
+
const MIGRATION_LOCK_KEY = "4839120657231098713";
|
|
35
44
|
var MigrationLockTimeoutError = class extends Error {
|
|
36
45
|
waitedMs;
|
|
37
46
|
constructor(waitedMs) {
|
|
@@ -92,14 +101,26 @@ async function runGuarded(fn) {
|
|
|
92
101
|
*
|
|
93
102
|
* The unlock query always runs after `fn` settles (success or failure) --
|
|
94
103
|
* this is expressed as unconditional sequential code (not a `try/finally`)
|
|
95
|
-
* so returning `outcome.value` never needs an unsafe cast to narrow `T`.
|
|
104
|
+
* so returning `outcome.value` never needs an unsafe cast to narrow `T`. The
|
|
105
|
+
* unlock query itself is guarded the same way `fn` is: if `fn` already
|
|
106
|
+
* succeeded (its work is already durable in Postgres) and the unlock query
|
|
107
|
+
* then fails -- e.g. the connection drops between commit and unlock -- that
|
|
108
|
+
* failure is logged rather than thrown, so a lock-release problem can never
|
|
109
|
+
* discard an already-successful `outcome.value`. Postgres releases a
|
|
110
|
+
* session-scoped advisory lock automatically once the session disconnects,
|
|
111
|
+
* so a failed unlock does not leave the lock stuck. If `fn` itself failed,
|
|
112
|
+
* `fn`'s error is what gets thrown regardless of the unlock outcome.
|
|
96
113
|
*/
|
|
97
114
|
async function withMigrationLock(client, fn, options) {
|
|
98
115
|
await acquire(client, options?.waitMs ?? DEFAULT_WAIT_MS, options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
99
116
|
const outcome = await runGuarded(fn);
|
|
100
|
-
const
|
|
117
|
+
const unlockOutcome = await runGuarded(() => client.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked", [MIGRATION_LOCK_KEY]));
|
|
101
118
|
if (outcome.ok) {
|
|
102
|
-
if (!
|
|
119
|
+
if (!unlockOutcome.ok) {
|
|
120
|
+
console.error("@plakboek/db: migrations applied successfully, but releasing the migration lock failed -- the lock releases automatically once this session disconnects", unlockOutcome.error);
|
|
121
|
+
return outcome.value;
|
|
122
|
+
}
|
|
123
|
+
if (!(unlockOutcome.value.rows[0]?.unlocked ?? false)) throw new Error("@plakboek/db: migration lock was not held at release -- this indicates a bug in withMigrationLock or an external pg_advisory_unlock call on the same key");
|
|
103
124
|
return outcome.value;
|
|
104
125
|
}
|
|
105
126
|
throw outcome.error;
|
|
@@ -120,12 +141,121 @@ async function withMigrationLock(client, fn, options) {
|
|
|
120
141
|
* Corrections are always a new, higher-numbered migration. See
|
|
121
142
|
* ../../MIGRATIONS.md for the full authoring convention.
|
|
122
143
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
144
|
+
* `0001_auth_core` is the first shipped entry: the better-auth core tables
|
|
145
|
+
* and the audit log. The runner's failure modes (ordering, checksums,
|
|
146
|
+
* partial application, locking) are exercised through fixture registries in
|
|
147
|
+
* this package's own tests (tests/fixtures/migrations.ts).
|
|
127
148
|
*/
|
|
128
|
-
const MIGRATIONS = Object.freeze([
|
|
149
|
+
const MIGRATIONS = Object.freeze([{
|
|
150
|
+
name: "0001_auth_core",
|
|
151
|
+
transactional: true,
|
|
152
|
+
sql: `
|
|
153
|
+
CREATE TABLE IF NOT EXISTS "user" (
|
|
154
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
155
|
+
"name" text NOT NULL,
|
|
156
|
+
"email" text NOT NULL,
|
|
157
|
+
"email_verified" boolean DEFAULT false NOT NULL,
|
|
158
|
+
"image" text,
|
|
159
|
+
"created_at" timestamp with time zone NOT NULL,
|
|
160
|
+
"updated_at" timestamp with time zone NOT NULL,
|
|
161
|
+
"role_key" text,
|
|
162
|
+
"banned" boolean,
|
|
163
|
+
"ban_reason" text,
|
|
164
|
+
"ban_expires" timestamp with time zone,
|
|
165
|
+
"two_factor_enabled" boolean DEFAULT false NOT NULL,
|
|
166
|
+
CONSTRAINT "user_email_unique" UNIQUE ("email")
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
CREATE TABLE IF NOT EXISTS "session" (
|
|
170
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
171
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
172
|
+
"token" text NOT NULL,
|
|
173
|
+
"created_at" timestamp with time zone NOT NULL,
|
|
174
|
+
"updated_at" timestamp with time zone NOT NULL,
|
|
175
|
+
"ip_address" text,
|
|
176
|
+
"user_agent" text,
|
|
177
|
+
"user_id" text NOT NULL,
|
|
178
|
+
"impersonated_by" text,
|
|
179
|
+
CONSTRAINT "session_token_unique" UNIQUE ("token"),
|
|
180
|
+
CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id")
|
|
181
|
+
REFERENCES "user" ("id") ON DELETE CASCADE
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE IF NOT EXISTS "account" (
|
|
185
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
186
|
+
"account_id" text NOT NULL,
|
|
187
|
+
"provider_id" text NOT NULL,
|
|
188
|
+
"user_id" text NOT NULL,
|
|
189
|
+
"access_token" text,
|
|
190
|
+
"refresh_token" text,
|
|
191
|
+
"id_token" text,
|
|
192
|
+
"access_token_expires_at" timestamp with time zone,
|
|
193
|
+
"refresh_token_expires_at" timestamp with time zone,
|
|
194
|
+
"scope" text,
|
|
195
|
+
"password" text,
|
|
196
|
+
"created_at" timestamp with time zone NOT NULL,
|
|
197
|
+
"updated_at" timestamp with time zone NOT NULL,
|
|
198
|
+
CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id")
|
|
199
|
+
REFERENCES "user" ("id") ON DELETE CASCADE
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
CREATE TABLE IF NOT EXISTS "verification" (
|
|
203
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
204
|
+
"identifier" text NOT NULL,
|
|
205
|
+
"value" text NOT NULL,
|
|
206
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
207
|
+
"created_at" timestamp with time zone NOT NULL,
|
|
208
|
+
"updated_at" timestamp with time zone NOT NULL
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
CREATE TABLE IF NOT EXISTS "two_factor" (
|
|
212
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
213
|
+
"secret" text NOT NULL,
|
|
214
|
+
"backup_codes" text NOT NULL,
|
|
215
|
+
"user_id" text NOT NULL,
|
|
216
|
+
"verified" boolean DEFAULT true,
|
|
217
|
+
"failed_verification_count" integer DEFAULT 0 NOT NULL,
|
|
218
|
+
"locked_until" timestamp with time zone,
|
|
219
|
+
CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id")
|
|
220
|
+
REFERENCES "user" ("id") ON DELETE CASCADE
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
CREATE TABLE IF NOT EXISTS "audit_log" (
|
|
224
|
+
"id" bigserial PRIMARY KEY NOT NULL,
|
|
225
|
+
"actor_user_id" text,
|
|
226
|
+
"actor_role_key" text NOT NULL,
|
|
227
|
+
"impersonator_user_id" text,
|
|
228
|
+
"permission" text NOT NULL,
|
|
229
|
+
"action" text NOT NULL,
|
|
230
|
+
"entity_type" text NOT NULL,
|
|
231
|
+
"entity_id" text,
|
|
232
|
+
"outcome" text NOT NULL,
|
|
233
|
+
"before" jsonb,
|
|
234
|
+
"after" jsonb,
|
|
235
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
236
|
+
CONSTRAINT "audit_log_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id")
|
|
237
|
+
REFERENCES "user" ("id") ON DELETE SET NULL,
|
|
238
|
+
CONSTRAINT "audit_log_impersonator_user_id_user_id_fk"
|
|
239
|
+
FOREIGN KEY ("impersonator_user_id")
|
|
240
|
+
REFERENCES "user" ("id") ON DELETE SET NULL
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
CREATE INDEX IF NOT EXISTS "session_user_id_idx"
|
|
244
|
+
ON "session" USING btree ("user_id");
|
|
245
|
+
CREATE INDEX IF NOT EXISTS "account_user_id_idx"
|
|
246
|
+
ON "account" USING btree ("user_id");
|
|
247
|
+
CREATE INDEX IF NOT EXISTS "verification_identifier_idx"
|
|
248
|
+
ON "verification" USING btree ("identifier");
|
|
249
|
+
CREATE INDEX IF NOT EXISTS "two_factor_user_id_idx"
|
|
250
|
+
ON "two_factor" USING btree ("user_id");
|
|
251
|
+
CREATE INDEX IF NOT EXISTS "two_factor_secret_idx"
|
|
252
|
+
ON "two_factor" USING btree ("secret");
|
|
253
|
+
CREATE INDEX IF NOT EXISTS "audit_log_actor_idx"
|
|
254
|
+
ON "audit_log" USING btree ("actor_user_id", "id");
|
|
255
|
+
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx"
|
|
256
|
+
ON "audit_log" USING btree ("created_at");
|
|
257
|
+
`
|
|
258
|
+
}]);
|
|
129
259
|
//#endregion
|
|
130
260
|
//#region src/migrate.ts
|
|
131
261
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plakboek/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Postgres connection factory and migration-lock primitives for Plakboek CMS installations",
|
|
5
5
|
"homepage": "https://github.com/flovan/plakboek/tree/main/packages/db#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"drizzle-orm": "^0.45.2"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
|
-
"node": ">=22.
|
|
49
|
+
"node": ">=22.18"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "tsdown",
|