@plakboek/db 0.3.0 → 0.5.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 CHANGED
@@ -54,12 +54,20 @@ const result = await runMigrations({
54
54
  connectionString: process.env.DATABASE_URL!,
55
55
  lockWaitMs: 60_000, // optional, default 60000
56
56
  lockPollIntervalMs: 250, // optional, default 250
57
+ onLockWait: () => console.log('Waiting for the migration lock'), // optional
58
+ onMigrationStart: (name) => console.log(`Applying ${name}`), // optional
57
59
  });
58
60
 
59
61
  // result.applied: names of migrations applied by this call
60
62
  // result.alreadyApplied: names that were already applied before this call
61
63
  ```
62
64
 
65
+ `onLockWait` fires once, the first time another migrator holds the lock, however
66
+ long the wait lasts. `onMigrationStart(name)` fires once per pending migration,
67
+ just before it applies, and never for an already-applied one. Hooks receive
68
+ migration names only. A hook that throws is ignored: it never aborts, rolls back
69
+ or reorders the run.
70
+
63
71
  `runMigrations` is safe to call from every instance of a rolling deploy: it
64
72
  opens its own connection, holds the advisory lock for the duration of the
65
73
  run, and applies this package's statically registered `MIGRATIONS` in
@@ -72,7 +80,10 @@ tables (`"user"`, `session`, `account`, `verification`, `two_factor`) and
72
80
  the append-only `audit_log`. `0002_content_engine` follows it, creating the
73
81
  content engine tables (`content_types`, `content_type_fields`,
74
82
  `content_entries`, `entry_revisions` and their history, seed, reference and
75
- settings tables) used by `@plakboek/content`. See
83
+ settings tables) used by `@plakboek/content`. `0003_page_block_engine`
84
+ creates the page and block-tree tables (`pages`, `page_blocks`,
85
+ `block_revisions`, `page_publications`, `page_url_history`,
86
+ `page_engine_settings`) used by `@plakboek/pages`. See
76
87
  [`MIGRATIONS.md`](./MIGRATIONS.md) for how later migrations are added, the
77
88
  transactional-vs-existence-guarded-statement decision, and what each thrown
78
89
  error class means.
package/dist/index.d.ts CHANGED
@@ -24,6 +24,9 @@ export declare class MigrationLockTimeoutError extends Error {
24
24
  type LockOptions = {
25
25
  readonly waitMs?: number;
26
26
  readonly pollIntervalMs?: number;
27
+ /** Called once, the first time the lock is not immediately available. A
28
+ * hook that throws is ignored: it never aborts the wait. */
29
+ readonly onWait?: () => void;
27
30
  };
28
31
  //#endregion
29
32
  //#region src/migrate.d.ts
@@ -75,6 +78,13 @@ type RunMigrationsOptions = {
75
78
  readonly connectionString: string;
76
79
  readonly lockWaitMs?: number;
77
80
  readonly lockPollIntervalMs?: number;
81
+ /** Called once, the first time the migration lock is held by another
82
+ * migrator. A hook that throws is ignored: it never aborts the run. */
83
+ readonly onLockWait?: () => void;
84
+ /** Called with a pending migration's name just before it applies; never for
85
+ * an already-applied migration. A hook that throws is ignored: it never
86
+ * aborts, rolls back or reorders the run. */
87
+ readonly onMigrationStart?: (name: string) => void;
78
88
  };
79
89
  type RunMigrationsResult = {
80
90
  readonly applied: readonly string[];
package/dist/index.js CHANGED
@@ -64,10 +64,17 @@ async function tryAcquire(client) {
64
64
  * is a session setting: it would persist on this client for every statement
65
65
  * run afterwards, including the migrations `withMigrationLock` wraps.
66
66
  */
67
- async function acquire(client, waitMs, pollIntervalMs) {
67
+ async function acquire(client, waitMs, pollIntervalMs, onWait) {
68
68
  const start = Date.now();
69
+ let reported = false;
69
70
  for (;;) {
70
71
  if (await tryAcquire(client)) return;
72
+ if (!reported) {
73
+ reported = true;
74
+ try {
75
+ onWait?.();
76
+ } catch {}
77
+ }
71
78
  const elapsedMs = Date.now() - start;
72
79
  if (elapsedMs >= waitMs) throw new MigrationLockTimeoutError(elapsedMs);
73
80
  await sleep(pollIntervalMs);
@@ -112,7 +119,7 @@ async function runGuarded(fn) {
112
119
  * `fn`'s error is what gets thrown regardless of the unlock outcome.
113
120
  */
114
121
  async function withMigrationLock(client, fn, options) {
115
- await acquire(client, options?.waitMs ?? DEFAULT_WAIT_MS, options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
122
+ await acquire(client, options?.waitMs ?? DEFAULT_WAIT_MS, options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, options?.onWait);
116
123
  const outcome = await runGuarded(fn);
117
124
  const unlockOutcome = await runGuarded(() => client.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked", [MIGRATION_LOCK_KEY]));
118
125
  if (outcome.ok) {
@@ -143,15 +150,17 @@ async function withMigrationLock(client, fn, options) {
143
150
  *
144
151
  * `0001_auth_core` is the first shipped entry: the better-auth core tables
145
152
  * 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).
153
+ * field and entry tables `@plakboek/content` builds on. `0003_page_block_engine`
154
+ * is the third: the page and block-tree tables `@plakboek/pages` builds on.
155
+ * The runner's failure modes (ordering, checksums, partial application,
156
+ * locking) are exercised through fixture registries in this package's own
157
+ * tests (tests/fixtures/migrations.ts).
150
158
  */
151
- const MIGRATIONS = Object.freeze([{
152
- name: "0001_auth_core",
153
- transactional: true,
154
- sql: `
159
+ const MIGRATIONS = Object.freeze([
160
+ {
161
+ name: "0001_auth_core",
162
+ transactional: true,
163
+ sql: `
155
164
  CREATE TABLE IF NOT EXISTS "user" (
156
165
  "id" text PRIMARY KEY NOT NULL,
157
166
  "name" text NOT NULL,
@@ -257,10 +266,11 @@ CREATE INDEX IF NOT EXISTS "audit_log_actor_idx"
257
266
  CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx"
258
267
  ON "audit_log" USING btree ("created_at");
259
268
  `
260
- }, {
261
- name: "0002_content_engine",
262
- transactional: true,
263
- sql: `
269
+ },
270
+ {
271
+ name: "0002_content_engine",
272
+ transactional: true,
273
+ sql: `
264
274
  CREATE TABLE IF NOT EXISTS "content_types" (
265
275
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
266
276
  "key" text NOT NULL,
@@ -504,7 +514,193 @@ CREATE INDEX IF NOT EXISTS "content_entry_references_target_idx"
504
514
  CREATE INDEX IF NOT EXISTS "content_field_key_history_field_idx"
505
515
  ON "content_field_key_history" USING btree ("field_id", "id");
506
516
  `
507
- }]);
517
+ },
518
+ {
519
+ name: "0003_page_block_engine",
520
+ transactional: true,
521
+ sql: `
522
+ CREATE TABLE IF NOT EXISTS "pages" (
523
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
524
+ "translation_group" uuid NOT NULL,
525
+ "locale" text NOT NULL,
526
+ "parent_page_id" uuid REFERENCES "pages" ("id") ON DELETE RESTRICT,
527
+ "slug" text NOT NULL,
528
+ "slug_source" text NOT NULL DEFAULT 'generated',
529
+ "path" text NOT NULL,
530
+ "resolved_path" text,
531
+ "title" text NOT NULL,
532
+ "status" text NOT NULL DEFAULT 'draft',
533
+ "seo" jsonb,
534
+ "version" integer NOT NULL DEFAULT 1,
535
+ "live_publication_id" uuid,
536
+ "locked_by" text,
537
+ "locked_at" timestamp with time zone,
538
+ "created_by" text,
539
+ "updated_by" text,
540
+ "created_at" timestamp with time zone NOT NULL,
541
+ "updated_at" timestamp with time zone NOT NULL,
542
+ "published_at" timestamp with time zone,
543
+ "first_published_at" timestamp with time zone,
544
+ "scheduled_at" timestamp with time zone,
545
+ "trashed_at" timestamp with time zone,
546
+ CONSTRAINT "pages_locked_by_user_id_fk" FOREIGN KEY ("locked_by")
547
+ REFERENCES "user" ("id") ON DELETE SET NULL,
548
+ CONSTRAINT "pages_created_by_user_id_fk" FOREIGN KEY ("created_by")
549
+ REFERENCES "user" ("id") ON DELETE SET NULL,
550
+ CONSTRAINT "pages_updated_by_user_id_fk" FOREIGN KEY ("updated_by")
551
+ REFERENCES "user" ("id") ON DELETE SET NULL,
552
+ CONSTRAINT "pages_locale_path_unique" UNIQUE ("locale", "path"),
553
+ CONSTRAINT "pages_group_locale_unique" UNIQUE ("translation_group", "locale"),
554
+ CONSTRAINT "pages_status_check"
555
+ CHECK ("status" IN ('draft', 'published', 'scheduled', 'trashed')),
556
+ CONSTRAINT "pages_slug_source_check"
557
+ CHECK ("slug_source" IN ('generated', 'manual')),
558
+ CONSTRAINT "pages_scheduled_at_check"
559
+ CHECK ("status" <> 'scheduled' OR "scheduled_at" IS NOT NULL),
560
+ CONSTRAINT "pages_resolved_path_check"
561
+ CHECK ("resolved_path" IS NULL OR "status" = 'published'),
562
+ CONSTRAINT "pages_lock_pair_check"
563
+ CHECK (("locked_by" IS NULL) = ("locked_at" IS NULL)),
564
+ CONSTRAINT "pages_path_check"
565
+ CHECK ("path" <> '' AND "path" NOT LIKE '/%' AND "path" NOT LIKE '%/')
566
+ );
567
+
568
+ CREATE TABLE IF NOT EXISTS "page_blocks" (
569
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
570
+ "owner_type" text NOT NULL,
571
+ "owner_id" uuid NOT NULL,
572
+ "locale" text NOT NULL,
573
+ "parent_block_id" uuid REFERENCES "page_blocks" ("id") ON DELETE CASCADE,
574
+ "block_type" text NOT NULL,
575
+ "props" jsonb NOT NULL DEFAULT '{}'::jsonb,
576
+ "schema_version" integer NOT NULL,
577
+ "depth" integer NOT NULL,
578
+ "sort_order" integer NOT NULL,
579
+ "version" integer NOT NULL DEFAULT 1,
580
+ "created_by" text,
581
+ "updated_by" text,
582
+ "created_at" timestamp with time zone NOT NULL,
583
+ "updated_at" timestamp with time zone NOT NULL,
584
+ CONSTRAINT "page_blocks_created_by_user_id_fk" FOREIGN KEY ("created_by")
585
+ REFERENCES "user" ("id") ON DELETE SET NULL,
586
+ CONSTRAINT "page_blocks_updated_by_user_id_fk" FOREIGN KEY ("updated_by")
587
+ REFERENCES "user" ("id") ON DELETE SET NULL,
588
+ CONSTRAINT "page_blocks_owner_type_check" CHECK ("owner_type" IN ('page')),
589
+ CONSTRAINT "page_blocks_props_object_check"
590
+ CHECK (jsonb_typeof("props") = 'object'),
591
+ CONSTRAINT "page_blocks_schema_version_check" CHECK ("schema_version" >= 1),
592
+ CONSTRAINT "page_blocks_depth_check" CHECK ("depth" >= 0)
593
+ );
594
+
595
+ CREATE TABLE IF NOT EXISTS "block_revisions" (
596
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
597
+ "block_id" uuid REFERENCES "page_blocks" ("id") ON DELETE SET NULL,
598
+ "owner_type" text NOT NULL,
599
+ "owner_id" uuid NOT NULL,
600
+ "locale" text NOT NULL,
601
+ "revision_batch_id" uuid NOT NULL,
602
+ "change_type" text NOT NULL,
603
+ "kind" text NOT NULL,
604
+ "block_type" text NOT NULL,
605
+ "parent_block_id" uuid,
606
+ "sort_order" integer NOT NULL,
607
+ "depth" integer NOT NULL,
608
+ "props" jsonb NOT NULL,
609
+ "schema_version" integer NOT NULL,
610
+ "author_id" text,
611
+ "created_at" timestamp with time zone NOT NULL,
612
+ CONSTRAINT "block_revisions_author_id_user_id_fk" FOREIGN KEY ("author_id")
613
+ REFERENCES "user" ("id") ON DELETE SET NULL,
614
+ CONSTRAINT "block_revisions_change_type_check"
615
+ CHECK ("change_type" IN ('create', 'update', 'move', 'delete')),
616
+ CONSTRAINT "block_revisions_kind_check" CHECK ("kind" IN ('save', 'publish')),
617
+ CONSTRAINT "block_revisions_props_object_check"
618
+ CHECK (jsonb_typeof("props") = 'object')
619
+ );
620
+
621
+ CREATE TABLE IF NOT EXISTS "page_publications" (
622
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
623
+ "page_id" uuid NOT NULL,
624
+ "locale" text NOT NULL,
625
+ "is_draft" boolean NOT NULL DEFAULT false,
626
+ "snapshot" jsonb NOT NULL,
627
+ "revision_manifest" jsonb NOT NULL,
628
+ "manifest_hash" text NOT NULL,
629
+ "published_by" text,
630
+ "published_at" timestamp with time zone NOT NULL,
631
+ CONSTRAINT "page_publications_page_id_fk" FOREIGN KEY ("page_id")
632
+ REFERENCES "pages" ("id") ON DELETE CASCADE,
633
+ CONSTRAINT "page_publications_published_by_user_id_fk" FOREIGN KEY ("published_by")
634
+ REFERENCES "user" ("id") ON DELETE SET NULL,
635
+ CONSTRAINT "page_publications_snapshot_object_check"
636
+ CHECK (jsonb_typeof("snapshot") = 'object'),
637
+ CONSTRAINT "page_publications_manifest_object_check"
638
+ CHECK (jsonb_typeof("revision_manifest") = 'object')
639
+ );
640
+
641
+ DO $$
642
+ BEGIN
643
+ IF NOT EXISTS (
644
+ SELECT 1 FROM pg_constraint WHERE conname = 'pages_live_publication_id_fk'
645
+ ) THEN
646
+ ALTER TABLE "pages"
647
+ ADD CONSTRAINT "pages_live_publication_id_fk"
648
+ FOREIGN KEY ("live_publication_id") REFERENCES "page_publications" ("id") ON DELETE SET NULL;
649
+ END IF;
650
+ END $$;
651
+
652
+ CREATE TABLE IF NOT EXISTS "page_url_history" (
653
+ "id" bigserial PRIMARY KEY,
654
+ "page_id" uuid,
655
+ "translation_group" uuid NOT NULL,
656
+ "locale" text NOT NULL,
657
+ "old_path" text NOT NULL,
658
+ "reason" text NOT NULL,
659
+ "changed_at" timestamp with time zone NOT NULL,
660
+ CONSTRAINT "page_url_history_page_id_fk" FOREIGN KEY ("page_id")
661
+ REFERENCES "pages" ("id") ON DELETE SET NULL,
662
+ CONSTRAINT "page_url_history_reason_check" CHECK (
663
+ "reason" IN ('slug_changed', 'moved', 'pattern_changed', 'unpublished', 'trashed', 'deleted')
664
+ )
665
+ );
666
+
667
+ CREATE TABLE IF NOT EXISTS "page_engine_settings" (
668
+ "id" smallint PRIMARY KEY DEFAULT 1,
669
+ "page_edit_locking" boolean NOT NULL DEFAULT true,
670
+ "url_pattern" text NOT NULL DEFAULT '{locale}/{path}',
671
+ "updated_at" timestamp with time zone NOT NULL DEFAULT now(),
672
+ CONSTRAINT "page_engine_settings_id_check" CHECK ("id" = 1)
673
+ );
674
+
675
+ INSERT INTO "page_engine_settings" ("id") VALUES (1) ON CONFLICT ("id") DO NOTHING;
676
+
677
+ CREATE INDEX IF NOT EXISTS "pages_parent_idx" ON "pages" USING btree ("parent_page_id");
678
+ CREATE INDEX IF NOT EXISTS "pages_locale_status_idx" ON "pages" USING btree ("locale", "status");
679
+ CREATE INDEX IF NOT EXISTS "pages_translation_group_idx" ON "pages" USING btree ("translation_group");
680
+ CREATE INDEX IF NOT EXISTS "pages_locale_path_prefix_idx"
681
+ ON "pages" USING btree ("locale", "path" text_pattern_ops);
682
+ CREATE UNIQUE INDEX IF NOT EXISTS "pages_locale_resolved_path_unique"
683
+ ON "pages" USING btree ("locale", "resolved_path")
684
+ WHERE "resolved_path" IS NOT NULL;
685
+ CREATE INDEX IF NOT EXISTS "page_blocks_owner_idx"
686
+ ON "page_blocks" USING btree ("owner_type", "owner_id", "locale");
687
+ CREATE INDEX IF NOT EXISTS "page_blocks_parent_sort_idx"
688
+ ON "page_blocks" USING btree ("parent_block_id", "sort_order");
689
+ CREATE INDEX IF NOT EXISTS "page_blocks_type_version_idx"
690
+ ON "page_blocks" USING btree ("block_type", "schema_version");
691
+ CREATE INDEX IF NOT EXISTS "block_revisions_owner_created_idx"
692
+ ON "block_revisions" USING btree ("owner_type", "owner_id", "locale", "created_at" DESC);
693
+ CREATE INDEX IF NOT EXISTS "block_revisions_batch_idx"
694
+ ON "block_revisions" USING btree ("revision_batch_id");
695
+ CREATE INDEX IF NOT EXISTS "block_revisions_block_idx"
696
+ ON "block_revisions" USING btree ("block_id", "created_at" DESC);
697
+ CREATE INDEX IF NOT EXISTS "page_publications_page_draft_published_idx"
698
+ ON "page_publications" USING btree ("page_id", "is_draft", "published_at" DESC);
699
+ CREATE INDEX IF NOT EXISTS "page_url_history_locale_path_idx"
700
+ ON "page_url_history" USING btree ("locale", "old_path");
701
+ `
702
+ }
703
+ ]);
508
704
  //#endregion
509
705
  //#region src/migrate.ts
510
706
  /**
@@ -652,7 +848,7 @@ async function applyNonTransactional(client, migration) {
652
848
  * registry order. Does not acquire any lock -- callers that need
653
849
  * concurrency safety use `migrateWithRegistry`. Exported directly so tests
654
850
  * can prove the unlocked case has no safety (the negative control). */
655
- async function applyPendingMigrations(client, migrations) {
851
+ async function applyPendingMigrations(client, migrations, onMigrationStart) {
656
852
  await ensureBookkeepingTable(client);
657
853
  const appliedRows = await readAppliedRows(client);
658
854
  validateAppliedHistory(appliedRows, migrations);
@@ -661,6 +857,9 @@ async function applyPendingMigrations(client, migrations) {
661
857
  const pending = migrations.filter((migration) => !alreadyAppliedSet.has(migration.name));
662
858
  const applied = [];
663
859
  for (const migration of pending) {
860
+ try {
861
+ onMigrationStart?.(migration.name);
862
+ } catch {}
664
863
  if (migration.transactional) await applyTransactional(client, migration);
665
864
  else await applyNonTransactional(client, migration);
666
865
  applied.push(migration.name);
@@ -678,9 +877,10 @@ async function migrateWithRegistry(options) {
678
877
  const client = new Client({ connectionString: options.connectionString });
679
878
  await client.connect();
680
879
  try {
681
- return await withMigrationLock(client, () => applyPendingMigrations(client, options.migrations), {
880
+ return await withMigrationLock(client, () => applyPendingMigrations(client, options.migrations, options.onMigrationStart), {
682
881
  waitMs: options.lockWaitMs,
683
- pollIntervalMs: options.lockPollIntervalMs
882
+ pollIntervalMs: options.lockPollIntervalMs,
883
+ onWait: options.onLockWait
684
884
  });
685
885
  } finally {
686
886
  await client.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plakboek/db",
3
- "version": "0.3.0",
3
+ "version": "0.5.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": {