@plakboek/db 0.1.2 → 0.3.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 +9 -4
  2. package/dist/index.js +363 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -67,10 +67,15 @@ 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`. `0002_content_engine` follows it, creating the
73
+ content engine tables (`content_types`, `content_type_fields`,
74
+ `content_entries`, `entry_revisions` and their history, seed, reference and
75
+ settings tables) used by `@plakboek/content`. See
76
+ [`MIGRATIONS.md`](./MIGRATIONS.md) for how later migrations are added, the
77
+ transactional-vs-existence-guarded-statement decision, and what each thrown
78
+ error class means.
74
79
 
75
80
  ## Errors
76
81
 
package/dist/index.js CHANGED
@@ -141,12 +141,370 @@ async function withMigrationLock(client, fn, options) {
141
141
  * Corrections are always a new, higher-numbered migration. See
142
142
  * ../../MIGRATIONS.md for the full authoring convention.
143
143
  *
144
- * MIGRATIONS ships empty at 0.1.0: no product table exists before Phase 2
145
- * introduces the first schema. The runner, bookkeeping table, advisory
146
- * lock, and every safety check are fully exercised through fixture
147
- * 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. `0002_content_engine` is the second: the content-type,
146
+ * field and entry tables `@plakboek/content` builds on. The runner's
147
+ * failure modes (ordering, checksums, partial application, locking) are
148
+ * exercised through fixture registries in this package's own tests
149
+ * (tests/fixtures/migrations.ts).
148
150
  */
149
- const MIGRATIONS = Object.freeze([]);
151
+ const MIGRATIONS = Object.freeze([{
152
+ name: "0001_auth_core",
153
+ transactional: true,
154
+ sql: `
155
+ CREATE TABLE IF NOT EXISTS "user" (
156
+ "id" text PRIMARY KEY NOT NULL,
157
+ "name" text NOT NULL,
158
+ "email" text NOT NULL,
159
+ "email_verified" boolean DEFAULT false NOT NULL,
160
+ "image" text,
161
+ "created_at" timestamp with time zone NOT NULL,
162
+ "updated_at" timestamp with time zone NOT NULL,
163
+ "role_key" text,
164
+ "banned" boolean,
165
+ "ban_reason" text,
166
+ "ban_expires" timestamp with time zone,
167
+ "two_factor_enabled" boolean DEFAULT false NOT NULL,
168
+ CONSTRAINT "user_email_unique" UNIQUE ("email")
169
+ );
170
+
171
+ CREATE TABLE IF NOT EXISTS "session" (
172
+ "id" text PRIMARY KEY NOT NULL,
173
+ "expires_at" timestamp with time zone NOT NULL,
174
+ "token" text NOT NULL,
175
+ "created_at" timestamp with time zone NOT NULL,
176
+ "updated_at" timestamp with time zone NOT NULL,
177
+ "ip_address" text,
178
+ "user_agent" text,
179
+ "user_id" text NOT NULL,
180
+ "impersonated_by" text,
181
+ CONSTRAINT "session_token_unique" UNIQUE ("token"),
182
+ CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id")
183
+ REFERENCES "user" ("id") ON DELETE CASCADE
184
+ );
185
+
186
+ CREATE TABLE IF NOT EXISTS "account" (
187
+ "id" text PRIMARY KEY NOT NULL,
188
+ "account_id" text NOT NULL,
189
+ "provider_id" text NOT NULL,
190
+ "user_id" text NOT NULL,
191
+ "access_token" text,
192
+ "refresh_token" text,
193
+ "id_token" text,
194
+ "access_token_expires_at" timestamp with time zone,
195
+ "refresh_token_expires_at" timestamp with time zone,
196
+ "scope" text,
197
+ "password" text,
198
+ "created_at" timestamp with time zone NOT NULL,
199
+ "updated_at" timestamp with time zone NOT NULL,
200
+ CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id")
201
+ REFERENCES "user" ("id") ON DELETE CASCADE
202
+ );
203
+
204
+ CREATE TABLE IF NOT EXISTS "verification" (
205
+ "id" text PRIMARY KEY NOT NULL,
206
+ "identifier" text NOT NULL,
207
+ "value" text NOT NULL,
208
+ "expires_at" timestamp with time zone NOT NULL,
209
+ "created_at" timestamp with time zone NOT NULL,
210
+ "updated_at" timestamp with time zone NOT NULL
211
+ );
212
+
213
+ CREATE TABLE IF NOT EXISTS "two_factor" (
214
+ "id" text PRIMARY KEY NOT NULL,
215
+ "secret" text NOT NULL,
216
+ "backup_codes" text NOT NULL,
217
+ "user_id" text NOT NULL,
218
+ "verified" boolean DEFAULT true,
219
+ "failed_verification_count" integer DEFAULT 0 NOT NULL,
220
+ "locked_until" timestamp with time zone,
221
+ CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id")
222
+ REFERENCES "user" ("id") ON DELETE CASCADE
223
+ );
224
+
225
+ CREATE TABLE IF NOT EXISTS "audit_log" (
226
+ "id" bigserial PRIMARY KEY NOT NULL,
227
+ "actor_user_id" text,
228
+ "actor_role_key" text NOT NULL,
229
+ "impersonator_user_id" text,
230
+ "permission" text NOT NULL,
231
+ "action" text NOT NULL,
232
+ "entity_type" text NOT NULL,
233
+ "entity_id" text,
234
+ "outcome" text NOT NULL,
235
+ "before" jsonb,
236
+ "after" jsonb,
237
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
238
+ CONSTRAINT "audit_log_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id")
239
+ REFERENCES "user" ("id") ON DELETE SET NULL,
240
+ CONSTRAINT "audit_log_impersonator_user_id_user_id_fk"
241
+ FOREIGN KEY ("impersonator_user_id")
242
+ REFERENCES "user" ("id") ON DELETE SET NULL
243
+ );
244
+
245
+ CREATE INDEX IF NOT EXISTS "session_user_id_idx"
246
+ ON "session" USING btree ("user_id");
247
+ CREATE INDEX IF NOT EXISTS "account_user_id_idx"
248
+ ON "account" USING btree ("user_id");
249
+ CREATE INDEX IF NOT EXISTS "verification_identifier_idx"
250
+ ON "verification" USING btree ("identifier");
251
+ CREATE INDEX IF NOT EXISTS "two_factor_user_id_idx"
252
+ ON "two_factor" USING btree ("user_id");
253
+ CREATE INDEX IF NOT EXISTS "two_factor_secret_idx"
254
+ ON "two_factor" USING btree ("secret");
255
+ CREATE INDEX IF NOT EXISTS "audit_log_actor_idx"
256
+ ON "audit_log" USING btree ("actor_user_id", "id");
257
+ CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx"
258
+ ON "audit_log" USING btree ("created_at");
259
+ `
260
+ }, {
261
+ name: "0002_content_engine",
262
+ transactional: true,
263
+ sql: `
264
+ CREATE TABLE IF NOT EXISTS "content_types" (
265
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
266
+ "key" text NOT NULL,
267
+ "slug" text NOT NULL,
268
+ "label_singular" text NOT NULL,
269
+ "label_plural" text NOT NULL,
270
+ "description" text,
271
+ "routable" boolean NOT NULL DEFAULT false,
272
+ "url_pattern" text,
273
+ "singleton" boolean NOT NULL DEFAULT false,
274
+ "drafts" boolean NOT NULL DEFAULT false,
275
+ "revisions" boolean NOT NULL DEFAULT false,
276
+ "revision_mode" text,
277
+ "edit_locking" boolean NOT NULL DEFAULT false,
278
+ "seo" boolean NOT NULL DEFAULT false,
279
+ "title_field_key" text,
280
+ "created_at" timestamp with time zone NOT NULL,
281
+ "updated_at" timestamp with time zone NOT NULL,
282
+ CONSTRAINT "content_types_key_unique" UNIQUE ("key"),
283
+ CONSTRAINT "content_types_slug_unique" UNIQUE ("slug"),
284
+ CONSTRAINT "content_types_revision_mode_check"
285
+ CHECK ("revision_mode" IN ('on_publish', 'on_every_save')),
286
+ CONSTRAINT "content_types_revision_mode_presence_check"
287
+ CHECK (
288
+ ("revisions" AND "revision_mode" IS NOT NULL)
289
+ OR (NOT "revisions" AND "revision_mode" IS NULL)
290
+ ),
291
+ CONSTRAINT "content_types_singleton_not_routable_check"
292
+ CHECK (NOT ("singleton" AND "routable"))
293
+ );
294
+
295
+ CREATE TABLE IF NOT EXISTS "content_type_fields" (
296
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
297
+ "content_type_id" uuid NOT NULL,
298
+ "key" text NOT NULL,
299
+ "label" text NOT NULL,
300
+ "field_type" text NOT NULL,
301
+ "translatable" boolean NOT NULL DEFAULT true,
302
+ "required" boolean NOT NULL DEFAULT false,
303
+ "options" jsonb NOT NULL DEFAULT '{}'::jsonb,
304
+ "widget" text NOT NULL,
305
+ "widget_options" jsonb NOT NULL DEFAULT '{}'::jsonb,
306
+ "default_value" jsonb,
307
+ "sort_order" integer NOT NULL,
308
+ "created_at" timestamp with time zone NOT NULL,
309
+ "updated_at" timestamp with time zone NOT NULL,
310
+ CONSTRAINT "content_type_fields_content_type_id_fk" FOREIGN KEY ("content_type_id")
311
+ REFERENCES "content_types" ("id") ON DELETE CASCADE,
312
+ CONSTRAINT "content_type_fields_type_key_unique" UNIQUE ("content_type_id", "key"),
313
+ CONSTRAINT "content_type_fields_field_type_check" CHECK (
314
+ "field_type" IN (
315
+ 'short_text', 'long_text', 'rich_text', 'number', 'integer', 'boolean',
316
+ 'date_time', 'select', 'multi_select', 'image', 'file', 'reference',
317
+ 'json', 'slug', 'url', 'repeater'
318
+ )
319
+ ),
320
+ CONSTRAINT "content_type_fields_key_check"
321
+ CHECK ("key" ~ '^[a-z][A-Za-z0-9]{0,63}$')
322
+ );
323
+
324
+ CREATE SEQUENCE IF NOT EXISTS "content_entry_public_id_seq";
325
+
326
+ CREATE TABLE IF NOT EXISTS "content_entries" (
327
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
328
+ "content_type_id" uuid NOT NULL,
329
+ "translation_group" uuid NOT NULL,
330
+ "public_id" bigint NOT NULL,
331
+ "locale" text NOT NULL,
332
+ "slug" text,
333
+ "slug_source" text,
334
+ "status" text NOT NULL DEFAULT 'draft',
335
+ "data" jsonb NOT NULL DEFAULT '{}'::jsonb,
336
+ "seo" jsonb,
337
+ "version" integer NOT NULL DEFAULT 1,
338
+ "draft_revision_id" uuid,
339
+ "live_revision_id" uuid,
340
+ "resolved_path" text,
341
+ "locked_by" text,
342
+ "locked_at" timestamp with time zone,
343
+ "created_by" text,
344
+ "updated_by" text,
345
+ "created_at" timestamp with time zone NOT NULL,
346
+ "updated_at" timestamp with time zone NOT NULL,
347
+ "published_at" timestamp with time zone,
348
+ "first_published_at" timestamp with time zone,
349
+ "scheduled_at" timestamp with time zone,
350
+ "trashed_at" timestamp with time zone,
351
+ CONSTRAINT "content_entries_content_type_id_fk" FOREIGN KEY ("content_type_id")
352
+ REFERENCES "content_types" ("id") ON DELETE RESTRICT,
353
+ CONSTRAINT "content_entries_locked_by_user_id_fk" FOREIGN KEY ("locked_by")
354
+ REFERENCES "user" ("id") ON DELETE SET NULL,
355
+ CONSTRAINT "content_entries_created_by_user_id_fk" FOREIGN KEY ("created_by")
356
+ REFERENCES "user" ("id") ON DELETE SET NULL,
357
+ CONSTRAINT "content_entries_updated_by_user_id_fk" FOREIGN KEY ("updated_by")
358
+ REFERENCES "user" ("id") ON DELETE SET NULL,
359
+ CONSTRAINT "content_entries_type_locale_slug_unique"
360
+ UNIQUE ("content_type_id", "locale", "slug"),
361
+ CONSTRAINT "content_entries_group_locale_unique"
362
+ UNIQUE ("translation_group", "locale"),
363
+ CONSTRAINT "content_entries_status_check"
364
+ CHECK ("status" IN ('draft', 'published', 'scheduled', 'trashed')),
365
+ CONSTRAINT "content_entries_slug_source_check"
366
+ CHECK ("slug_source" IN ('generated', 'manual')),
367
+ CONSTRAINT "content_entries_data_object_check"
368
+ CHECK (jsonb_typeof("data") = 'object'),
369
+ CONSTRAINT "content_entries_scheduled_at_check"
370
+ CHECK ("status" <> 'scheduled' OR "scheduled_at" IS NOT NULL),
371
+ CONSTRAINT "content_entries_resolved_path_check"
372
+ CHECK ("resolved_path" IS NULL OR "status" = 'published'),
373
+ CONSTRAINT "content_entries_lock_pair_check"
374
+ CHECK (("locked_by" IS NULL) = ("locked_at" IS NULL))
375
+ );
376
+
377
+ CREATE TABLE IF NOT EXISTS "entry_revisions" (
378
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
379
+ "entry_id" uuid NOT NULL,
380
+ "locale" text NOT NULL,
381
+ "kind" text NOT NULL,
382
+ "data" jsonb NOT NULL,
383
+ "field_ids" jsonb NOT NULL,
384
+ "seo" jsonb,
385
+ "slug" text,
386
+ "author_id" text,
387
+ "created_at" timestamp with time zone NOT NULL,
388
+ CONSTRAINT "entry_revisions_entry_id_fk" FOREIGN KEY ("entry_id")
389
+ REFERENCES "content_entries" ("id") ON DELETE CASCADE,
390
+ CONSTRAINT "entry_revisions_author_id_user_id_fk" FOREIGN KEY ("author_id")
391
+ REFERENCES "user" ("id") ON DELETE SET NULL,
392
+ CONSTRAINT "entry_revisions_kind_check" CHECK ("kind" IN ('save', 'publish'))
393
+ );
394
+
395
+ DO $$
396
+ BEGIN
397
+ IF NOT EXISTS (
398
+ SELECT 1 FROM pg_constraint WHERE conname = 'content_entries_draft_revision_id_fk'
399
+ ) THEN
400
+ ALTER TABLE "content_entries"
401
+ ADD CONSTRAINT "content_entries_draft_revision_id_fk"
402
+ FOREIGN KEY ("draft_revision_id") REFERENCES "entry_revisions" ("id") ON DELETE SET NULL;
403
+ END IF;
404
+ END $$;
405
+
406
+ DO $$
407
+ BEGIN
408
+ IF NOT EXISTS (
409
+ SELECT 1 FROM pg_constraint WHERE conname = 'content_entries_live_revision_id_fk'
410
+ ) THEN
411
+ ALTER TABLE "content_entries"
412
+ ADD CONSTRAINT "content_entries_live_revision_id_fk"
413
+ FOREIGN KEY ("live_revision_id") REFERENCES "entry_revisions" ("id") ON DELETE SET NULL;
414
+ END IF;
415
+ END $$;
416
+
417
+ CREATE TABLE IF NOT EXISTS "content_type_key_history" (
418
+ "id" bigserial PRIMARY KEY,
419
+ "content_type_id" uuid NOT NULL,
420
+ "old_key" text,
421
+ "new_key" text NOT NULL,
422
+ "changed_by" text,
423
+ "changed_at" timestamp with time zone NOT NULL,
424
+ CONSTRAINT "content_type_key_history_content_type_id_fk" FOREIGN KEY ("content_type_id")
425
+ REFERENCES "content_types" ("id") ON DELETE CASCADE,
426
+ CONSTRAINT "content_type_key_history_changed_by_user_id_fk" FOREIGN KEY ("changed_by")
427
+ REFERENCES "user" ("id") ON DELETE SET NULL
428
+ );
429
+
430
+ CREATE TABLE IF NOT EXISTS "content_field_key_history" (
431
+ "id" bigserial PRIMARY KEY,
432
+ "content_type_id" uuid NOT NULL,
433
+ "field_id" uuid NOT NULL,
434
+ "old_key" text,
435
+ "new_key" text NOT NULL,
436
+ "changed_by" text,
437
+ "changed_at" timestamp with time zone NOT NULL,
438
+ CONSTRAINT "content_field_key_history_content_type_id_fk" FOREIGN KEY ("content_type_id")
439
+ REFERENCES "content_types" ("id") ON DELETE CASCADE,
440
+ CONSTRAINT "content_field_key_history_changed_by_user_id_fk" FOREIGN KEY ("changed_by")
441
+ REFERENCES "user" ("id") ON DELETE SET NULL
442
+ );
443
+
444
+ CREATE TABLE IF NOT EXISTS "content_entry_url_history" (
445
+ "id" bigserial PRIMARY KEY,
446
+ "entry_id" uuid,
447
+ "content_type_id" uuid NOT NULL,
448
+ "translation_group" uuid NOT NULL,
449
+ "locale" text NOT NULL,
450
+ "old_path" text NOT NULL,
451
+ "reason" text NOT NULL,
452
+ "changed_at" timestamp with time zone NOT NULL,
453
+ CONSTRAINT "content_entry_url_history_entry_id_fk" FOREIGN KEY ("entry_id")
454
+ REFERENCES "content_entries" ("id") ON DELETE SET NULL,
455
+ CONSTRAINT "content_entry_url_history_content_type_id_fk" FOREIGN KEY ("content_type_id")
456
+ REFERENCES "content_types" ("id") ON DELETE CASCADE,
457
+ CONSTRAINT "content_entry_url_history_reason_check" CHECK (
458
+ "reason" IN ('slug_changed', 'pattern_changed', 'unpublished', 'trashed', 'deleted')
459
+ )
460
+ );
461
+
462
+ CREATE TABLE IF NOT EXISTS "content_type_seed_applications" (
463
+ "seed_id" text PRIMARY KEY,
464
+ "kind" text NOT NULL,
465
+ "target_id" uuid NOT NULL,
466
+ "applied_at" timestamp with time zone NOT NULL,
467
+ CONSTRAINT "content_type_seed_applications_kind_check" CHECK ("kind" IN ('type', 'field'))
468
+ );
469
+
470
+ CREATE TABLE IF NOT EXISTS "content_entry_references" (
471
+ "source_entry_id" uuid NOT NULL,
472
+ "field_id" uuid NOT NULL,
473
+ "target_translation_group" uuid NOT NULL,
474
+ PRIMARY KEY ("source_entry_id", "field_id", "target_translation_group"),
475
+ CONSTRAINT "content_entry_references_source_entry_id_fk" FOREIGN KEY ("source_entry_id")
476
+ REFERENCES "content_entries" ("id") ON DELETE CASCADE
477
+ );
478
+
479
+ CREATE TABLE IF NOT EXISTS "content_engine_settings" (
480
+ "id" smallint PRIMARY KEY DEFAULT 1,
481
+ "revision_cap" integer NOT NULL DEFAULT 0,
482
+ "updated_at" timestamp with time zone NOT NULL DEFAULT now(),
483
+ CONSTRAINT "content_engine_settings_id_check" CHECK ("id" = 1),
484
+ CONSTRAINT "content_engine_settings_revision_cap_check" CHECK ("revision_cap" >= 0)
485
+ );
486
+
487
+ INSERT INTO "content_engine_settings" ("id") VALUES (1) ON CONFLICT ("id") DO NOTHING;
488
+
489
+ CREATE INDEX IF NOT EXISTS "content_entries_type_locale_status_idx"
490
+ ON "content_entries" USING btree ("content_type_id", "locale", "status");
491
+ CREATE INDEX IF NOT EXISTS "content_entries_translation_group_idx"
492
+ ON "content_entries" USING btree ("translation_group");
493
+ CREATE INDEX IF NOT EXISTS "content_entries_public_id_idx"
494
+ ON "content_entries" USING btree ("public_id");
495
+ CREATE UNIQUE INDEX IF NOT EXISTS "content_entries_locale_resolved_path_unique"
496
+ ON "content_entries" USING btree ("locale", "resolved_path")
497
+ WHERE "resolved_path" IS NOT NULL;
498
+ CREATE INDEX IF NOT EXISTS "entry_revisions_entry_kind_created_idx"
499
+ ON "entry_revisions" USING btree ("entry_id", "kind", "created_at");
500
+ CREATE INDEX IF NOT EXISTS "content_entry_url_history_locale_path_idx"
501
+ ON "content_entry_url_history" USING btree ("locale", "old_path");
502
+ CREATE INDEX IF NOT EXISTS "content_entry_references_target_idx"
503
+ ON "content_entry_references" USING btree ("target_translation_group");
504
+ CREATE INDEX IF NOT EXISTS "content_field_key_history_field_idx"
505
+ ON "content_field_key_history" USING btree ("field_id", "id");
506
+ `
507
+ }]);
150
508
  //#endregion
151
509
  //#region src/migrate.ts
152
510
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plakboek/db",
3
- "version": "0.1.2",
3
+ "version": "0.3.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": {