@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.
Files changed (3) hide show
  1. package/README.md +10 -9
  2. package/dist/index.js +138 -8
  3. 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, holding a Postgres advisory lock for the whole run so
12
- concurrent migrators can never double-apply (D-03). What Phase 6's deploy
13
- step invokes.
14
- - `withMigrationLock`, `MigrationLockTimeoutError` -- the session-scoped
15
- advisory lock primitive `runMigrations` wraps around every run.
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` ships empty at `0.1.0` -- no product schema exists before
71
- Phase 2. See [`MIGRATIONS.md`](./MIGRATIONS.md) for how later phases add to
72
- it, the transactional-vs-existence-guarded-statement decision, and what
73
- each thrown error class means.
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 unlocked = (await client.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked", ["4839120657231098713"])).rows[0]?.unlocked ?? false;
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 (!unlocked) 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");
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
- * MIGRATIONS ships empty at 0.1.0: no product table exists before Phase 2
124
- * introduces the first schema. The runner, bookkeeping table, advisory
125
- * lock, and every safety check are fully exercised through fixture
126
- * registries in this package's own tests (tests/fixtures/migrations.ts).
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.1.1",
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.16"
49
+ "node": ">=22.18"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsdown",