From 807401332923dfb6d7c65283e88a2dd5bebcf43a Mon Sep 17 00:00:00 2001 From: NGPixel Date: Mon, 12 Oct 2026 03:44:29 -0400 Subject: [PATCH] fix: git sync on multiple replicas deployments --- CLAUDE.md | 83 +- backend/core/maintenance.ts | 10 + .../migration.sql | 26 + .../snapshot.json | 9269 +++++++++++++++++ backend/db/schema.ts | 64 + backend/helpers/storageFiles.ts | 7 +- backend/models/storage.ts | 209 +- backend/modules/storage/git/definition.yml | 20 +- backend/modules/storage/git/storage.ts | 1205 ++- backend/tasks/simple/sync-storage-targets.ts | 8 +- dev/chart/README.md | 7 +- dev/chart/values.yaml | 9 +- 12 files changed, 10394 insertions(+), 523 deletions(-) create mode 100644 backend/db/migrations/20261012072149_storage-outbox/migration.sql create mode 100644 backend/db/migrations/20261012072149_storage-outbox/snapshot.json diff --git a/CLAUDE.md b/CLAUDE.md index 69808b5aa..ad20424bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -966,9 +966,9 @@ Everything else follows from that: database's. Imported content is rendered with **no script or style permission** whoever ran the import, since the file need not have been written by them. - **An import is never written back to the target it reads from.** What it adopts is saved like any - edit and so copied to every target holding it, and `git` and `sftp` serialize every operation per - target — with the import itself being one, a copy back to the source waits on the import that is - waiting on it. `importTree` runs under `storage.importingFrom(target)`, an `AsyncLocalStorage` + edit and so copied to every target holding it, and `sftp` serializes every operation per target — + with the import itself being one, a copy back to the source waits on the import that is waiting on + it. For `git` the copy back would be a queued change re-publishing what was just pulled. `importTree` runs under `storage.importingFrom(target)`, an `AsyncLocalStorage` scope that `putAsset`, `eachPageTarget` and `eachAssetTarget` skip that target in (counting it, for `putAsset`, as somewhere the file is held). Anything else a module does to the wiki while holding its own queue — git's removals in `applyIncoming` — needs the same scope. @@ -1002,15 +1002,25 @@ misconfigured one reads healthy until something is actually asked of it. **The git target is the disk target plus history and a remote.** Same tree, same helpers, and then: -- **Commits are local and immediate; the network is batched.** A page save writes, commits and - returns — `prepareRepo` deliberately never contacts a remote, so an unreachable one cannot make the - wiki slow to edit or fail an upload. `ensureRemote` is the half that does, and only `sync` calls it. +- **The working copy is a cache; the database is what every instance agrees on.** An HA set has a + working copy per pod, on no shared volume and perhaps on no volume at all, so nothing is kept only + there. A page save or an upload queues a row in `storageOutbox` — the file as written, who wrote it, + when — and that is all it does: nothing on the request path touches git or the network. The commit + the wiki and the remote last agreed on is `storageSyncState`, recorded against the remote URL and + branch it belongs to. A sync, on whichever instance runs it, `checkoutAt`s that commit (wiping + whatever its own copy held, an interrupted rebase and a stale `index.lock` included), `replay`s the + queue as one commit per change with the original author and date, settles with the remote, pushes, + applies what came back, and only then drops the published rows and records the new commit, in one + transaction. Rows are dropped by id, never by range, since ids are not handed out in commit order. +- **One sync per target across the whole set.** `storage.exclusivelyFor` holds a session advisory lock + (`pg_try_advisory_lock(TARGET_LOCK_CLASS, hashtext(targetId))`) for the length of it; an instance + that finds it taken reports so and does nothing, since the holder is doing the same job. `withRepo` + serializes within the process on top of that. - **`sync` runs on a schedule.** `tasks/simple/sync-storage-targets.ts`, on a `* * * * *` `jobSchedule` row seeded by `jobs.init()`, walks every enabled target whose module declares a `sync` handler (`storage.syncableTargets`) and runs it through `executeAction`, so a failure lands on the - target's Status card. The **Force Sync** action is the same call on demand. In an HA set the - scheduler hands the job to one instance, which is the one whose working copy syncs — each instance - keeps its own. + target's Status card. The **Force Sync** action is the same call on demand. Which instance gets a + tick does not matter. - **How often is `storage.syncInterval`, per site** (the **Configuration** tab, `syncIntervalFor`, default `5m`). Since it is per site, one cron row cannot express it: the tick is every minute — as fine as the shortest interval anyone can set — and the task skips the sites whose turn it is not. @@ -1024,38 +1034,41 @@ misconfigured one reads healthy until something is actually asked of it. has to come off the tree rather than the file: the stem is looked up first and only counts as the page if `pages.storageFileNameOf` matches the name that went, so a deleted `readme.pdf` never takes the page `readme` with it. -- **A conflict is settled, never left open.** The pull is `--rebase --autostash`, and a rebase that - conflicts stops and waits for a human that this working copy does not have — with the index - unmerged, which git then refuses every write against, so it is not one failed sync but every commit - after it (`ensureRemote`'s checkout is the first thing to fail, and what such a target reports). - `pullRebase` therefore rolls a conflicted rebase back and pulls again with `-X ours`, which during a - rebase is the *remote*, consistent with a pull being authoritative. What `-X` cannot settle is a - file one side changed and the other deleted; that fails the sync, but leaves the working copy usable. - `abortInterrupted` also runs at the top of every sync and in `prepareRepo`, so a copy somebody left - mid-rebase by hand heals itself instead of needing Purge. -- **A working copy started again from nothing re-attaches itself.** It is a directory in a container - while the content is a database elsewhere, so an upgrade that replaces the container without a volume - for `data/repo` takes it with it — and `prepareRepo` then inits an empty one, the next page save - commits into it, and the wiki has the remote's own files under a second root commit. `sync` asks - `sharesHistoryWith` before it pulls, and with no commit in common `reattach` merges the remote's - history in with `--allow-unrelated-histories -X ours` instead: the remote's history and every file - the wiki has not re-saved come back, the working copy's version wins where the two overlap (these - files were written from the database *since* the copy was created, so the remote's copy is the older - one), and the push that follows puts the two in step. It runs in **every** mode — a `push` mode force - push from a working copy created ten minutes ago would otherwise replace the whole repository with - it. The wiki itself is not written to and `applyIncoming` is skipped for that sync, since the diff is - the entire repository; Import Everything is what takes in content the repository has and the wiki - does not. +- **A conflict is settled, never left open.** The queue's commits go onto the remote with `rebase + --onto origin/ `, and a rebase that conflicts stops and waits for a human + this working copy does not have. `rebaseOnto` therefore rolls a conflicted rebase back and runs it + again with `-X ours`, which during a rebase is the *remote*, consistent with a pull being + authoritative; what `-X` cannot settle — a file one side changed and the other deleted — is finished + by `settleRebase`, taking stage 2 (the remote's side) of every unmerged path. A sync that stopped + there would fail every sync after it, since the queue it could not publish is the next one's too. +- **Changed in the wiki while a sync ran** means queued after it read the queue: the remote's change + to that path is not applied over it, and the next sync publishes the wiki's. In **Pull only** the + wiki queues nothing at all (`acceptsWrites`, and `canStore` declines assets), and a row left from + before the switch is dropped once the remote changes its path. +- **The first sync with a remote applies nothing.** With no agreed commit — a new target, one pointed + at another URL or branch, an installation upgraded from before the queue existed, or a remote whose + history was rewritten past the agreed commit — the remote's branch is taken up as it stands and the + queue committed on top; there is nothing to diff it against. Import Everything is what takes in + content the repository has and the wiki does not, and it reads the agreed commit's tree out of git + (`ls-tree`, `cat-file`) rather than off any working tree. +- **An asset is read out of git, not off the working tree** (`readBlob`), which on any given instance + is wherever its last sync left it. Not yet published, its bytes are the queue's — followed back + through a queued move — and after that they are the agreed commit's, fetched first by an instance + that has never seen it. The fetch is the only network a read can cost, and only on a cold instance. - **The diff, not the tree.** Incoming changes come from `git diff --name-status -M -z` between the - commit the branch was on and the one it is on now — a sync runs every few minutes and cannot read + commit the branch was on after the replay and the one it is on after the rebase — a sync runs every few minutes and cannot read every file each time. `-z` because a path may contain anything, newlines included. -- **Every operation is serialized per target** by `withRepo`: git locks its index for the length of a - write, so two concurrent uploads would otherwise have one fail on `index.lock`. - **An empty commit is never made.** Most page saves do not change the stored form of the page (a re-publish, a tag reordered into the same order), and `commitPaths` checks `diff --cached` before committing so the history says what actually changed. - Operator git config is inherited on purpose — including `commit.gpgsign`, which will fail every - commit if it is on without a usable key. The failure surfaces as the target's recorded `error`. + commit if it is on without a usable key. The failure surfaces as the target's recorded `error`, and + the queue is kept for the sync after it. simple-git strips every ambient `GIT_*` variable before + spawning git, so `GIT_CONFIG_GLOBAL` cannot switch that off for a test instance; a `HOME` of its own + can. +- **Without a Repository URI the repository is one instance's own**: the queue is committed into + whichever working copy syncs, and nothing else holds those commits. That needs a single instance on + a persistent volume; with a remote, neither is needed. **A change carries who made it, and only git cares.** `StorageAssetRef` and `StoragePageRef` carry an optional `actorId`, which is the user id the models already had in hand at each dispatch site; diff --git a/backend/core/maintenance.ts b/backend/core/maintenance.ts index 6cc5209a8..941f10236 100644 --- a/backend/core/maintenance.ts +++ b/backend/core/maintenance.ts @@ -55,6 +55,7 @@ export default { await WIKI.models.assets.purgeCache() await WIKI.models.icons.purgeCache() await WIKI.models.blocks.purgeCache() + WIKI.models.storage.targetCache.clear() await WIKI.models.locales.reloadCache() await WIKI.models.sites.reloadCache() @@ -94,6 +95,15 @@ export default { WIKI.events.inbound.on('reloadSites', async () => { await WIKI.models.sites.reloadCache() }) + // -> Storage targets are cached per instance too, and decide where every upload is written. Dropped + // rather than reloaded: the next request that needs them reads them back. + WIKI.events.inbound.on('reloadStorageTargets', ({ data: siteId }) => { + if (typeof siteId === 'string') { + WIKI.models.storage.targetCache.delete(siteId) + } else { + WIKI.models.storage.targetCache.clear() + } + }) WIKI.events.inbound.on('reloadBlocks', async () => { await WIKI.models.blocks.refreshCustomIndex() await WIKI.models.blocks.sweepCache() diff --git a/backend/db/migrations/20261012072149_storage-outbox/migration.sql b/backend/db/migrations/20261012072149_storage-outbox/migration.sql new file mode 100644 index 000000000..47c25f40d --- /dev/null +++ b/backend/db/migrations/20261012072149_storage-outbox/migration.sql @@ -0,0 +1,26 @@ +CREATE TABLE "storageOutbox" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "storageOutbox_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "targetId" uuid NOT NULL, + "kind" varchar(16) NOT NULL, + "op" varchar(16) NOT NULL, + "path" text NOT NULL, + "previousPath" text, + "label" text NOT NULL, + "previousLabel" text, + "data" bytea, + "actorId" uuid, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "storageSyncState" ( + "targetId" uuid PRIMARY KEY, + "remote" text NOT NULL, + "branch" varchar(255) NOT NULL, + "commit" varchar(64) NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "storageOutbox_targetId_idx" ON "storageOutbox" ("targetId","id");--> statement-breakpoint +CREATE INDEX "storageOutbox_path_idx" ON "storageOutbox" ("targetId","path");--> statement-breakpoint +ALTER TABLE "storageOutbox" ADD CONSTRAINT "storageOutbox_targetId_storage_id_fkey" FOREIGN KEY ("targetId") REFERENCES "storage"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "storageSyncState" ADD CONSTRAINT "storageSyncState_targetId_storage_id_fkey" FOREIGN KEY ("targetId") REFERENCES "storage"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/backend/db/migrations/20261012072149_storage-outbox/snapshot.json b/backend/db/migrations/20261012072149_storage-outbox/snapshot.json new file mode 100644 index 000000000..e3ef923fe --- /dev/null +++ b/backend/db/migrations/20261012072149_storage-outbox/snapshot.json @@ -0,0 +1,9269 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "45629f29-b7ce-47e2-aa7a-c97d53d3d72b", + "prevIds": [ + "7a20a82e-9831-485f-8e2a-8440487434db" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "open", + "finished", + "failed" + ], + "name": "importSessionState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "commentAnnotations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "glossaryTermRelations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "glossaryTerms", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importIdMap", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importSessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notificationEvents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageLinks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRatings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storageOutbox", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storageSyncState", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userNotificationPrefs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "clientIP", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "displayName", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "registration", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "allowedEmailRegex", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "autoEnrollGroups", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "block", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isCustom", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "packageData", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "checksum", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commentId", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "note", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "anchor", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "commentAnnotations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorName", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorEmail", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorIP", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "termId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "term", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expansion", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "documentationPath", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "documentationLabel", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "references", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "caseSensitive", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "autoLink", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "glossaryTerms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnFirstLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogout", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sessionId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "namespace", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "sites", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "includes", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "overwrite", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'markdown'", + "generated": null, + "identity": null, + "name": "htmlConversion", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "importSessionState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "progress", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "warnings", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completeness", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "items", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "recipients", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cursor", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimedAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimedBy", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processedAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "notificationEvents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variant", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commentId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "count", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastEventId", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "inApp", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "emailState", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailAfter", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailedAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetSiteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLocale", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetPath", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetRef", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRatings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localeGroupId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "ratings", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "storageOutbox_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "op", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousPath", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousLabel", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "storageOutbox" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "storageSyncState" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote", + "entityType": "columns", + "schema": "public", + "table": "storageSyncState" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "branch", + "entityType": "columns", + "schema": "public", + "table": "storageSyncState" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commit", + "entityType": "columns", + "schema": "public", + "table": "storageSyncState" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "storageSyncState" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "commentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "commentAnnotations_commentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "commentAnnotations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_page_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_parentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTermRelations_relatedId_idx", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTerms_site_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lower(\"term\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTerms_site_locale_term_key", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "documentationPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"documentationPath\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "glossaryTerms_documentation_idx", + "entityType": "indexes", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "groups_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "groups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "importSessions_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"processedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notificationEvents_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "notificationEvents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "processedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"processedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notificationEvents_processed_idx", + "entityType": "indexes", + "schema": "public", + "table": "notificationEvents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_updated_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"readAt\" IS NULL AND \"inApp\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "emailAfter", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailState\" IN ('pending', 'sending')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"readAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_group_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "commentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_commentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetLocale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetRef", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_targetRef_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "href", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_href_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRatings_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "localeGroupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_localeGroupId_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_locale_path_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storageOutbox_targetId_idx", + "entityType": "indexes", + "schema": "public", + "table": "storageOutbox" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storageOutbox_path_idx", + "entityType": "indexes", + "schema": "public", + "table": "storageOutbox" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "hash", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"enabled\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "userNotificationPrefs_optin_idx", + "entityType": "indexes", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"handle\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "commentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "commentAnnotations_commentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "commentAnnotations" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "commentAnnotations_resolvedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "commentAnnotations" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_parentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comments_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "termId" + ], + "schemaTo": "public", + "tableTo": "glossaryTerms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "glossaryTermRelations_termId_glossaryTerms_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "nameExplicit": false, + "columns": [ + "relatedId" + ], + "schemaTo": "public", + "tableTo": "glossaryTerms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "glossaryTermRelations_relatedId_glossaryTerms_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "glossaryTerms_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "glossaryTerms_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "glossaryTerms_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "glossaryTerms" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "sessionId" + ], + "schemaTo": "public", + "tableTo": "importSessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "importIdMap_sessionId_importSessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importIdMap" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "importSessions_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "commentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_commentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notifications_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "targetSiteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_targetSiteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageLinks_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRatings_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRatings_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRatings" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "targetId" + ], + "schemaTo": "public", + "tableTo": "storage", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "storageOutbox_targetId_storage_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storageOutbox" + }, + { + "nameExplicit": false, + "columns": [ + "targetId" + ], + "schemaTo": "public", + "tableTo": "storage", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "storageSyncState_targetId_storage_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storageSyncState" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userNotificationPrefs_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "columns": [ + "termId", + "relatedId" + ], + "nameExplicit": false, + "name": "glossaryTermRelations_pkey", + "entityType": "pks", + "schema": "public", + "table": "glossaryTermRelations" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "sessionId", + "entity", + "sourceId" + ], + "nameExplicit": false, + "name": "importIdMap_pkey", + "entityType": "pks", + "schema": "public", + "table": "importIdMap" + }, + { + "columns": [ + "pageId", + "userId" + ], + "nameExplicit": false, + "name": "pageRatings_pkey", + "entityType": "pks", + "schema": "public", + "table": "pageRatings" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "userId", + "category", + "channel" + ], + "nameExplicit": false, + "name": "userNotificationPrefs_pkey", + "entityType": "pks", + "schema": "public", + "table": "userNotificationPrefs" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "auditLog_pkey", + "schema": "public", + "table": "auditLog", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authentication_pkey", + "schema": "public", + "table": "authentication", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pkey", + "schema": "public", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "commentAnnotations_pkey", + "schema": "public", + "table": "commentAnnotations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "comments_pkey", + "schema": "public", + "table": "comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "glossaryTerms_pkey", + "schema": "public", + "table": "glossaryTerms", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "importSessions_pkey", + "schema": "public", + "table": "importSessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notificationEvents_pkey", + "schema": "public", + "table": "notificationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notifications_pkey", + "schema": "public", + "table": "notifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageLinks_pkey", + "schema": "public", + "table": "pageLinks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storageOutbox_pkey", + "schema": "public", + "table": "storageOutbox", + "entityType": "pks" + }, + { + "columns": [ + "targetId" + ], + "nameExplicit": false, + "name": "storageSyncState_pkey", + "schema": "public", + "table": "storageSyncState", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + }, + { + "value": "\"termId\" < \"relatedId\"", + "name": "glossaryTermRelations_order_check", + "entityType": "checks", + "schema": "public", + "table": "glossaryTermRelations" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 77a330b2a..32b2d115f 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -1411,6 +1411,70 @@ export const storage = pgTable( (table) => [uniqueIndex('storage_composite_idx').on(table.siteId, table.module)] ) +/** + * Changes a target has accepted and not yet published, oldest first. + * + * For a target whose own copy of the content is not somewhere every instance can see — the git + * target, whose working copy is a directory on whichever pod made the change. A change is recorded + * here instead, by whichever instance made it, and the target's sync replays the queue as commits on + * whichever instance runs it. So the queue is what every instance of an HA set agrees on, and a + * working copy is a cache any of them can rebuild. + * + * `data` is a snapshot of the file as it was written, not a pointer to the page or asset: forty saves + * between two syncs are forty commits, each with what that save wrote, and an asset whose only home is + * this target has its bytes nowhere else until the sync has pushed them. + */ +export const storageOutbox = pgTable( + 'storageOutbox', + { + // -> Replay order. Bigint because every page save on a busy wiki takes one. + id: bigint({ mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), + targetId: uuid() + .notNull() + .references(() => storage.id, { onDelete: 'cascade' }), + // -> Which decides the commit message + kind: varchar({ length: 16 }).$type<'page' | 'asset'>().notNull(), + op: varchar({ length: 16 }).$type<'put' | 'delete' | 'move'>().notNull(), + // -> Relative to the target's root, as the layout files it. Where a move ends up. + path: text().notNull(), + // -> Where a move started + previousPath: text(), + // -> How a commit message names the content, worked out when the change was made + label: text().notNull(), + previousLabel: text(), + // -> The file as written, for a `put` + data: bytea(), + // -> Not a foreign key: a commit made after the account is gone still happened, and falls back to + // the target's default author + actorId: uuid(), + createdAt: timestamp().notNull().defaultNow() + }, + (table) => [ + index('storageOutbox_targetId_idx').on(table.targetId, table.id), + // -> A read of a file this target has accepted and not yet published + index('storageOutbox_path_idx').on(table.targetId, table.path) + ] +) + +/** + * Where a target that syncs with a remote last left off, as every instance sees it. + * + * `commit` is the remote's commit the wiki and the remote last agreed on: everything the outbox held up + * to then was pushed in it, and everything the remote had up to then was applied to the wiki. It is + * only meaningful for the remote and branch it was recorded against, which is why those are kept with + * it — a target pointed somewhere else starts again from that remote rather than diffing against a + * commit the remote has never had. + */ +export const storageSyncState = pgTable('storageSyncState', { + targetId: uuid() + .primaryKey() + .references(() => storage.id, { onDelete: 'cascade' }), + remote: text().notNull(), + branch: varchar({ length: 255 }).notNull(), + commit: varchar({ length: 64 }).notNull(), + updatedAt: timestamp().notNull().defaultNow() +}) + // TAGS -------------------------------- export const tags = pgTable( 'tags', diff --git a/backend/helpers/storageFiles.ts b/backend/helpers/storageFiles.ts index 376bfbd59..74cd9f0c9 100644 --- a/backend/helpers/storageFiles.ts +++ b/backend/helpers/storageFiles.ts @@ -53,6 +53,11 @@ export const ROOT_PAGE_NAME = 'index' */ const IGNORED_SEGMENT = /^\.|\.tmp$/ +/** Whether a stored path is one of those, and so never content. */ +export function isIgnoredPath(segments: string[]): boolean { + return segments.some((segment) => IGNORED_SEGMENT.test(segment)) +} + /** * A configured root as an absolute path. * @@ -352,7 +357,7 @@ export async function walkStored(root: string): Promise { } const filePath = path.join(entry.parentPath, entry.name) const segments = path.relative(root, filePath).split(path.sep) - if (segments.some((segment) => IGNORED_SEGMENT.test(segment))) { + if (isIgnoredPath(segments)) { continue } files.push({ filePath, segments }) diff --git a/backend/models/storage.ts b/backend/models/storage.ts index 89e607384..bf7a447a5 100644 --- a/backend/models/storage.ts +++ b/backend/models/storage.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import fs from 'node:fs/promises' import path from 'node:path' import { load } from 'js-yaml' -import { and, eq, inArray } from 'drizzle-orm' +import { and, asc, desc, eq, getTableColumns, inArray, lt } from 'drizzle-orm' import { CustomError, dataPathRoot, @@ -11,7 +11,12 @@ import { parseModuleProps, resolveLocalPath } from '../helpers/common.ts' -import { sites as sitesTable, storage as storageTable } from '../db/schema.ts' +import { + sites as sitesTable, + storage as storageTable, + storageOutbox as storageOutboxTable, + storageSyncState as storageSyncStateTable +} from '../db/schema.ts' import type { ModuleProp } from '../helpers/common.ts' import type { AssetKind } from './assets.ts' @@ -29,6 +34,12 @@ const DB_MODULE = 'db' /** The target an import in progress is reading from. See `Storage.importingFrom`. */ const importSource = new AsyncLocalStorage() +/** + * The first half of every per-target advisory lock (`exclusivelyFor`), the target's id hashed being + * the second. The two-key form is a keyspace of its own, apart from the single-key locks elsewhere. + */ +const TARGET_LOCK_CLASS = 4210003 + /** Which content type an asset falls into when it is not large enough to count as a large file. */ const CONTENT_TYPE_BY_KIND: Record = { image: 'images', @@ -96,10 +107,9 @@ const SIZE_UNIT_BYTES: Record = { /** * How long the configured targets of a site are trusted before they are read again. * - * The backstop rather than the mechanism: saving the admin form drops the entry on the instance that - * handled the request, but a second instance has no way to hear about it — so every entry expires on - * its own as well. Short enough that a change made elsewhere takes effect promptly, long enough that - * a burst of uploads resolves its target once rather than once per file. + * The backstop rather than the mechanism: a saved change drops the entry on every instance at once + * (`invalidateTargets`), and this only bounds how long an instance that missed the notification — its + * listening connection was down at the time — goes on answering from the old configuration. */ const TARGET_CACHE_TTL_MS = 30_000 @@ -415,6 +425,34 @@ export interface StoragePageContent { content: string } +/** A change as a target queues it. See `storageOutbox` in the schema. */ +export interface StorageOutboxChange { + kind: 'page' | 'asset' + op: 'put' | 'delete' | 'move' + path: string + previousPath?: string | null + label: string + previousLabel?: string | null + data?: Buffer | null + actorId?: string | null +} + +/** A queued change as it is read back, without its bytes. */ +export interface StorageQueuedChange extends Omit { + id: number + previousPath: string | null + previousLabel: string | null + actorId: string | null + createdAt: Date +} + +/** Where a target that syncs last agreed with its remote. See `storageSyncState` in the schema. */ +export interface StorageSyncState { + remote: string + branch: string + commit: string +} + /** * What a storage module implementation exports. * @@ -755,6 +793,20 @@ class Storage { return targets } + /** + * Drop a site's cached targets here and on every other instance. + * + * A target's configuration decides where an upload is written and whether a page save is queued for + * git at all, so an instance answering from the old one for the length of `TARGET_CACHE_TTL_MS` + * would write into a target that was just switched off — or, for a git target just set to Pull + * only, queue changes nothing will ever publish. Not emitted from `syncSite`, which every instance + * runs for itself at boot. + */ + invalidateTargets(siteId: string): void { + this.targetCache.delete(siteId) + WIKI.events.outbound.emit('reloadStorageTargets', siteId) + } + /** * The same list, answered from memory where it can be. * @@ -1072,7 +1124,7 @@ class Storage { .update(storageTable) .set(values) .where(and(eq(storageTable.siteId, siteId), eq(storageTable.id, target.id))) - this.targetCache.delete(siteId) + this.invalidateTargets(siteId) return (result.rowCount ?? 0) > 0 } @@ -1237,7 +1289,7 @@ class Storage { } const updated = await WIKI.models.sites.updateSite(siteId, { config: { storage: config } }) // -> A target's content type depends on the threshold, so the resolved list is now stale - this.targetCache.delete(siteId) + this.invalidateTargets(siteId) return updated } @@ -1779,6 +1831,147 @@ class Storage { return actor } + /** + * Record a change a target has accepted, for its sync to publish. + * + * What a target does instead of writing to its own copy when that copy is not somewhere every + * instance can see. See `storageOutbox` in the schema. + */ + async queueChange(target: StorageTarget, change: StorageOutboxChange): Promise { + await WIKI.db.insert(storageOutboxTable).values({ ...change, targetId: target.id }) + } + + /** + * Every change queued for a target, oldest first — without the bytes, which `queuedData` reads one + * at a time. An offload can queue every file a site has, and holding all of them at once would be + * the whole asset table in memory. + */ + async queuedChanges(target: StorageTarget): Promise { + const { data: _data, ...columns } = getTableColumns(storageOutboxTable) + return WIKI.db + .select(columns) + .from(storageOutboxTable) + .where(eq(storageOutboxTable.targetId, target.id)) + .orderBy(asc(storageOutboxTable.id)) + } + + /** The file a queued `put` wrote. */ + async queuedData(id: number): Promise { + const [row] = await WIKI.db + .select({ data: storageOutboxTable.data }) + .from(storageOutboxTable) + .where(eq(storageOutboxTable.id, id)) + return row?.data ?? null + } + + /** + * The latest queued change that left something at this path, or took it away. + * + * @param beforeId Only changes queued before this one — for following a move back to where the + * file was before it + */ + async latestQueuedAt( + target: StorageTarget, + relPath: string, + beforeId?: number + ): Promise { + const { data: _data, ...columns } = getTableColumns(storageOutboxTable) + const [row] = await WIKI.db + .select(columns) + .from(storageOutboxTable) + .where( + and( + eq(storageOutboxTable.targetId, target.id), + eq(storageOutboxTable.path, relPath), + beforeId === undefined ? undefined : lt(storageOutboxTable.id, beforeId) + ) + ) + .orderBy(desc(storageOutboxTable.id)) + .limit(1) + return row ?? null + } + + /** Where a target that syncs last agreed with its remote, if it has. See `storageSyncState`. */ + async syncStateFor(target: StorageTarget): Promise { + const [row] = await WIKI.db + .select({ + remote: storageSyncStateTable.remote, + branch: storageSyncStateTable.branch, + commit: storageSyncStateTable.commit + }) + .from(storageSyncStateTable) + .where(eq(storageSyncStateTable.targetId, target.id)) + return row ?? null + } + + /** + * Finish a sync: drop the changes it published and record where it left off, both or neither. + * + * By id rather than by "everything up to the last one": ids are handed out when a row is inserted + * and become visible when it commits, which is not the same order, so a change saved on another + * instance while the sync ran can carry a lower id than one the sync already read. + */ + async settleSync( + target: StorageTarget, + { published, state }: { published: number[]; state: StorageSyncState | null } + ): Promise { + await WIKI.db.transaction(async (trx) => { + if (published.length > 0) { + await trx.delete(storageOutboxTable).where(inArray(storageOutboxTable.id, published)) + } + if (state) { + await trx + .insert(storageSyncStateTable) + .values({ targetId: target.id, ...state }) + .onConflictDoUpdate({ + target: storageSyncStateTable.targetId, + set: { ...state, updatedAt: new Date() } + }) + } + }) + } + + /** + * Run something no other instance is running for this target at the same time. + * + * A session-level advisory lock on a connection held for the length of the work, rather than a + * transaction-level one, because the work is a sync — minutes of network and git — and a + * transaction held open across it would pin rows as well as a connection. Postgres drops the lock + * with the connection, so an instance that dies mid-sync does not leave it held. + * + * @returns `{ busy: true }` without running anything when another instance holds it. A sync is + * periodic: the instance that has it is doing the job, and waiting to do it again straight after is + * no use to anybody. + */ + async exclusivelyFor( + target: StorageTarget, + work: () => Promise + ): Promise<{ busy: true } | { busy: false; result: T }> { + const client = await WIKI.dbManager.pool!.connect() + let broken = false + try { + const { rows } = await client.query( + 'SELECT pg_try_advisory_lock($1, hashtext($2)) AS locked', + [TARGET_LOCK_CLASS, target.id] + ) + if (!rows[0]?.locked) { + return { busy: true } + } + try { + return { busy: false, result: await work() } + } finally { + await client + .query('SELECT pg_advisory_unlock($1, hashtext($2))', [TARGET_LOCK_CLASS, target.id]) + .catch(() => { + // -> A connection that cannot unlock is thrown away, which unlocks it + broken = true + }) + } + } finally { + client.release(broken) + } + } + /** * Ensure a module's implementation is loaded * diff --git a/backend/modules/storage/git/definition.yml b/backend/modules/storage/git/definition.yml index 4a756e8aa..f2a2df4b0 100644 --- a/backend/modules/storage/git/definition.yml +++ b/backend/modules/storage/git/definition.yml @@ -2,7 +2,7 @@ key: git title: Git icon: '/_assets/icons/ultraviolet-git.svg' banner: '/_assets/storage/git.jpg' -description: Keep this site's content in a Git repository, committed as it changes and synchronized with a remote. Every page and file is an ordinary versioned file, so the wiki's history is also the repository's. +description: Keep this site's content in a Git repository, committed per change and synchronized with a remote. Every page and file is an ordinary versioned file, so the wiki's history is also the repository's. Changes are committed and pushed by each sync, from any instance, so it needs no persistent or shared volume. assetDelivery: isDirectAccessSupported: false contentTypes: @@ -22,21 +22,21 @@ props: repoUrl: type: String title: Repository URI - hint: Git-compliant URI (e.g. git@server.com:org/repo.git for SSH, https://server.com/org/repo.git for basic). Leave empty to keep a purely local repository, committed but never pushed. + hint: Git-compliant URI (e.g. git@server.com:org/repo.git for SSH, https://server.com/org/repo.git for basic). Leave empty to keep a purely local repository, committed but never pushed - which lives on one instance's disk only, so needs a persistent volume and a single instance. icon: dns order: 2 branch: type: String default: 'main' title: Branch - hint: The branch to use during pull / push. It must already exist on the remote. + hint: The branch to use during pull / push. The first push creates it on a remote that has no branches yet. icon: code-fork order: 3 syncMode: type: String default: 'sync' title: Sync Direction - hint: Sync pulls and then pushes. Push force-pushes the wiki's commits and never takes anything back, so the wiki always wins. Pull only takes changes in, and is what makes the remote the authority. + hint: Sync pulls and then pushes. Push force-pushes the wiki's commits and never takes anything back, so the wiki always wins. Pull only takes changes in and never writes the wiki's own to the repository, which makes the remote the authority. icon: synchronize enum: - sync|Sync @@ -127,7 +127,7 @@ props: type: String title: Local Repository Path default: './data/repo' - hint: Where the working copy is kept. Give each site its own path unless you turn on Add Site ID Prefix under Configuration, since two sites sharing a repository would otherwise write over each other. Relative paths are resolved from the Wiki.js install directory. + hint: Where each instance keeps its working copy, which is a cache it rebuilds from the remote whenever it needs to. Give each site its own path unless you turn on Add Site ID Prefix under Configuration, since two sites sharing a repository would otherwise write over each other. Relative paths are resolved from the Wiki.js install directory. icon: symlink-directory order: 32 localPath: data @@ -146,15 +146,15 @@ actions: icon: synchronize syncUntracked: label: Add Untracked Changes - hint: Write every page and file this target is configured to hold into the repository and commit whatever is missing. Content created before Git was enabled - or while it was turned off - is untracked until this is run. + hint: Write every page and file this target is configured to hold into the repository, commit whatever is missing and sync. Content created before Git was enabled - or while it was turned off - is untracked until this is run. icon: database-daily-export importAll: label: Import Everything - hint: Take everything currently in the local repository into the wiki, whatever the last commit did. For picking up content from a remote repository that existed before Git was enabled here. - warn: The repository wins every collision. A page it replaces keeps its previous version in its history, but a file has none - the bytes it replaces are gone from every storage target holding them. + hint: Take everything the repository held at the last sync into the wiki, whatever the last commit did. For picking up content from a remote repository that existed before Git was enabled here, which the first sync does not take in. + warn: The repository wins every collision, except with changes the wiki has not published yet. A page it replaces keeps its previous version in its history, but a file has none - the bytes it replaces are gone from every storage target holding them. icon: database-daily-import purge: label: Purge Local Repository - hint: Empty the local working copy and clone it again from the remote. A working copy that was replaced re-attaches itself on the next sync, so this is for one git can no longer make sense of at all. The remote is not touched and nothing is committed. - warn: Any commit that exists only in the local repository and has never been pushed is lost. Run a Force Sync first if you are not sure. + hint: Delete the working copy of the instance that answers this request. The next sync there takes it again from the remote, with the changes the wiki has queued on top, so nothing is lost. The remote is not touched and nothing is committed. + warn: With no Repository URI configured, the working copy is the whole repository, and its history is deleted with it. icon: trash diff --git a/backend/modules/storage/git/storage.ts b/backend/modules/storage/git/storage.ts index 68314feb9..dfc2810e7 100644 --- a/backend/modules/storage/git/storage.ts +++ b/backend/modules/storage/git/storage.ts @@ -5,15 +5,20 @@ import { absPathIn, assetRelPath, importTree, + isIgnoredPath, moveStored, pageRelPath, resolveRoot, - serializePage, - walkStored + serializePage } from '../../../helpers/storageFiles.ts' import type { ImportSummary, StoredFile } from '../../../helpers/storageFiles.ts' import type { SimpleGit } from 'simple-git' -import type { StorageModule, StoragePageRef, StorageTarget } from '../../../models/storage.ts' +import type { + StorageModule, + StoragePageRef, + StorageQueuedChange, + StorageTarget +} from '../../../models/storage.ts' /** Where the working copy goes when the target has no path configured, per the definition default. */ const DEFAULT_REPO_PATH = './data/repo' @@ -22,7 +27,17 @@ const DEFAULT_REPO_PATH = './data/repo' const FALLBACK_AUTHOR = { name: 'Wiki.js', email: 'wiki@example.com' } /** - * One repository, as this module keeps it between operations. + * How many times a stopped rebase is moved on by hand before giving up on it. Each turn settles one + * replayed commit, so this is far more than a sync ever replays; it exists so that a git that keeps + * stopping for a reason this module does not understand fails the sync rather than spinning. + */ +const SETTLE_LIMIT = 1000 + +/** How many moves an unpublished asset is followed back through to find its bytes. */ +const MAX_MOVE_HOPS = 100 + +/** + * This instance's working copy, as this module keeps it between operations. * * Cached against the configuration it was set up from, so that changing a setting in the admin area * takes effect on the next operation rather than at the next restart. @@ -32,8 +47,6 @@ interface Repo { root: string /** The configuration this was prepared from — see `configFingerprint`. */ fingerprint: string - /** Whether the remote has been contacted since this entry was made. See `ensureRemote`. */ - remoteReady: boolean } const repos = new Map() @@ -41,6 +54,9 @@ const repos = new Map() /** The last operation queued against each target. See `withRepo`. */ const queues = new Map>() +/** A fetch in progress for an asset read, per target. See `readBlob`. */ +const fetches = new Map>() + /** The working copy for this target, as an absolute path. */ function repoDir(target: StorageTarget): string { return resolveRoot(target.config.localRepoPath, DEFAULT_REPO_PATH) @@ -115,12 +131,11 @@ function remoteUrl(target: StorageTarget): string { } /** - * Prepare the local repository, without touching the network. + * Prepare this instance's working copy, without touching the network. * - * Everything an ordinary page save needs: a working copy that exists, is a repository, knows who it - * is committing as and has `origin` pointing where the configuration says. Deliberately *not* the - * fetch and the checkout — see `ensureRemote`. A page save must not wait on a remote, and with the - * commits made locally and pushed by the sync there is no reason for it to. + * A working copy that exists, is a repository, knows who it is committing as and has `origin` + * pointing where the configuration says. What is checked out in it is the sync's business — see + * `checkoutAt` — since a copy prepared on a pod that started a minute ago holds nothing yet. */ async function prepareRepo(target: StorageTarget): Promise { const root = repoDir(target) @@ -207,17 +222,16 @@ async function prepareRepo(target: StorageTarget): Promise { return { git, root, - fingerprint: configFingerprint(target), - remoteReady: false + fingerprint: configFingerprint(target) } } /** - * Run something against this target's repository, one operation at a time. + * Run something against this instance's working copy of a target, one operation at a time. * - * Git takes a lock on the index for the length of a write, so two uploads landing together would have - * one of them fail on `index.lock` rather than wait. Serializing here is what makes concurrent saves - * safe, and the queue is per target because two targets are two working copies. + * Git takes a lock on the index for the length of a write, so two operations landing together would + * have one of them fail on `index.lock` rather than wait. This serializes within the process only; + * what keeps two instances from syncing the same target at once is `exclusive`. */ async function withRepo(target: StorageTarget, run: (repo: Repo) => Promise): Promise { const turn = async () => { @@ -243,60 +257,213 @@ async function withRepo(target: StorageTarget, run: (repo: Repo) => Promise { - const branch = target.config.branch || 'main' - if (!target.config.repoUrl) { - return { onRemote: false } +async function exclusive( + target: StorageTarget, + busy: string, + run: (repo: Repo) => Promise +): Promise { + const outcome = await WIKI.models.storage.exclusivelyFor(target, () => withRepo(target, run)) + return outcome.busy ? busy : outcome.result +} + +/** The branch a target syncs, as configured. */ +function branchOf(target: StorageTarget): string { + return target.config.branch || 'main' +} + +/** Which way a target syncs. See the definition's `syncMode`. */ +function modeOf(target: StorageTarget): 'sync' | 'push' | 'pull' { + const mode = target.config.syncMode + return mode === 'push' || mode === 'pull' ? mode : 'sync' +} + +/** + * Whether the wiki's own changes are recorded for this target at all. + * + * Not in Pull only, the mode that makes the remote the only author: nothing queued would ever be + * sent, and a queue that is never drained is a row per page save for ever. + */ +function acceptsWrites(target: StorageTarget): boolean { + return modeOf(target) !== 'pull' +} + +/** + * Where this target last agreed with its remote, if that is still the remote it is pointed at. + * + * Recorded against a URL and a branch and only good for those: a commit of a repository the target + * has since been pointed away from is nothing to diff against, so the next sync starts again from + * whatever the new remote has. + */ +async function syncedCommit(target: StorageTarget): Promise { + const state = await WIKI.models.storage.syncStateFor(target) + if ( + !state || + state.remote !== (target.config.repoUrl || '') || + state.branch !== branchOf(target) + ) { + return null } - if (!repo.remoteReady) { - await repo.git.raw(['remote', 'update', 'origin', '--prune']) + return state.commit +} + +/** What a ref points at here, or null for one this working copy does not have. */ +async function resolveRef(repo: Repo, ref: string): Promise { + // -> `--verify --quiet` exits non-zero for a ref that does not exist, which simple-git raises + return repo.git.raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).then( + (out) => out.trim() || null, + () => null + ) +} + +/** Whether this working copy has a commit, which a fresh one has not until it has fetched. */ +async function hasCommit(repo: Repo, commit: string): Promise { + return repo.git.raw(['cat-file', '-e', `${commit}^{commit}`]).then( + () => true, + () => false + ) +} + +/** + * Bring the remote's branches into this working copy, without touching what is checked out. + * + * A remote that does not have the branch yet is the ordinary state of a repository somebody has just + * created, and the first push is what creates it — 2.x refused to start at all in that case, which + * made an empty remote something an administrator had to go and fix by hand before the wiki would + * talk to it. What is still worth refusing is a branch that exists nowhere on a remote that has other + * branches, because that is a typo rather than a beginning. + * + * @returns The commit the remote's branch is on, or null when it does not have the branch yet + */ +async function fetchRemote(repo: Repo, target: StorageTarget): Promise { + const branch = branchOf(target) + await repo.git.raw(['fetch', '--prune', 'origin']) + const head = await resolveRef(repo, `refs/remotes/origin/${branch}`) + if (head) { + return head } + const others = (await repo.git.raw(['branch', '-r', '--format=%(refname:lstrip=3)'])) + .split('\n') + .map((name) => name.trim()) + .filter((name) => name && name !== 'HEAD') + if (others.length > 0) { + throw new Error( + `(STORAGE/GIT) The branch "${branch}" does not exist on the remote, which has ${others.join(', ')}. Check the branch name, or create it on the remote first.` + ) + } + return null +} - const branches = await repo.git.branch(['-a']) - const onRemote = branches.all.includes(`remotes/origin/${branch}`) - const onLocal = branches.all.includes(branch) +/** + * Put the working copy at a commit, exactly: that commit's files and nothing else. + * + * This is what keeps the working copy a cache. Whatever was left in it — by a process that died part + * way through a rebase, by an earlier sync whose push failed, by somebody with a shell — is not what a + * sync builds on; the commit the wiki last agreed with the remote on is. So all of it goes, an + * unfinished rebase and a stale index lock included: nothing else can be using this working copy + * while a sync holds it, so a lock still there is one nobody is coming back for. + * + * @param commit Null for a branch with nothing on it yet, which leaves an empty one to commit into + */ +async function checkoutAt(repo: Repo, branch: string, commit: string | null): Promise { + await fs.rm(path.join(repo.root, '.git', 'index.lock'), { force: true }) + await abortInterrupted(repo) + if (commit) { + await repo.git.raw(['checkout', '--force', '-B', branch, commit]) + } else { + await repo.git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branch}`]) + await repo.git.raw(['update-ref', '-d', `refs/heads/${branch}`]).catch(() => null) + await repo.git.raw(['read-tree', '--empty']) + } + await repo.git.raw(['clean', '-ffdx']) +} - /* - A remote that does not have the branch yet is the ordinary state of a repository somebody has just - created, and the first push is what creates it — 2.x refused to start at all in that case, which - made an empty remote something an administrator had to go and fix by hand before the wiki would - talk to it. - - What is still worth refusing is a branch that exists nowhere on a remote that has other branches, - because that is a typo rather than a beginning. - */ - if (!onRemote && !onLocal) { - const remoteBranches = branches.all.filter((b) => b.startsWith('remotes/origin/')) - if (remoteBranches.length > 0) { - throw new Error( - `(STORAGE/GIT) The branch "${branch}" does not exist locally or on the remote, which has ${remoteBranches - .map((b) => b.replace('remotes/origin/', '')) - .join(', ')}. Check the branch name, or create it on the remote first.` +/** + * Commit the changes queued for this target, one commit each, in the order they were made. + * + * Each is replayed as it was recorded: the file as it was written, by whoever wrote it, dated when + * they did. What the replay adds is only what can be read off the working copy at that point — + * whether a page being written is new, and whether a file being moved is there to move. + * + * A change that leaves the tree as it was — a page saved without a change to its stored form, a file + * under one of the repository's own ignore rules, a move of something the repository never had — + * commits nothing, and still counts as published: there is nothing left of it to send. + * + * @returns The ids of the changes replayed, which the caller drops from the queue once they have + * reached the remote + */ +async function replay( + repo: Repo, + target: StorageTarget, + changes: StorageQueuedChange[] +): Promise { + const replayed: number[] = [] + for (const change of changes) { + const by = { actorId: change.actorId, date: change.createdAt } + if (change.op === 'put') { + const data = await WIKI.models.storage.queuedData(change.id) + const filePath = absPathIn(repo.root, change.path) + // -> Which of the two verbs a page's commit gets. Read before the write, since afterwards every + // page looks like one that was already there. + const existed = await fs.access(filePath).then( + () => true, + () => false + ) + if (data && !(await isIgnored(repo, change.path))) { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, data) + await repo.git.raw(['add', '--', change.path]) + await commitPaths( + repo, + target, + [change.path], + change.kind === 'page' + ? `docs: ${existed ? 'update' : 'create'} ${change.label}` + : `docs: upload ${change.label}`, + by + ) + } + } else if (change.op === 'delete') { + await fs.rm(absPathIn(repo.root, change.path), { force: true }) + // -> From the index rather than `git rm`, which fails on a path git has never heard of — an + // ordinary situation here, for a file deleted before this target was enabled + await repo.git.raw(['rm', '--cached', '--ignore-unmatch', '--quiet', '--', change.path]) + await commitPaths(repo, target, [change.path], `docs: delete ${change.label}`, by) + } else if ( + change.previousPath && + (await moveStored(repo.root, change.previousPath, change.path)) === 'moved' + ) { + const paths = [change.previousPath, change.path] + for (const relPath of paths) { + await repo.git.add(['-A', '--', relPath]) + } + await commitPaths( + repo, + target, + paths, + `docs: rename ${change.previousLabel} to ${change.label}`, + by ) } - } else if (onRemote && branches.current !== branch) { - WIKI.logger.info(`(STORAGE/GIT) Checking out branch ${branch}...`) - await repo.git.checkout(branch) + replayed.push(change.id) } - - repo.remoteReady = true - return { onRemote } + return replayed } /** * What git has stopped in the middle of, if anything. * * A rebase or a merge that hits a conflict is not an operation that failed and ended — it is one that - * is still going, waiting for somebody to resolve it, with the index unmerged until they do. Nothing - * here resolves anything, so the only question worth asking is whether the working copy is in that - * state at all. + * is still going, waiting for somebody to resolve it, with the index unmerged until they do. */ async function interruptedBy(repo: { git: SimpleGit }): Promise<'rebase' | 'merge' | null> { const gitDir = await repo.git.revparse(['--absolute-git-dir']) @@ -306,8 +473,8 @@ async function interruptedBy(repo: { git: SimpleGit }): Promise<'rebase' | 'merg () => false ) // -> Two directories because there are two rebase backends: `rebase-merge` for the default one and - // `rebase-apply` for `--apply`, which is what an administrator working in the repository by hand - // may well have left behind + // `rebase-apply` for `--apply`, which is what somebody working in the repository by hand may well + // have left behind if ((await exists('rebase-merge')) || (await exists('rebase-apply'))) { return 'rebase' } @@ -317,63 +484,55 @@ async function interruptedBy(repo: { git: SimpleGit }): Promise<'rebase' | 'merg /** * Put the working copy back the way it was before an operation git could not finish. * - * The one state this module must never leave behind. An unfinished rebase holds the index unmerged, - * and git refuses to check out a branch or make a commit against an unmerged index — so it is not one - * sync that failed but everything after it: every page save, every upload and every later sync, until - * somebody notices and runs Purge. Aborting restores the branch and the working tree to what they - * were, so nothing local is lost and the next sync simply tries again. - * - * @returns What was rolled back, if anything, so that a report can say it happened + * An unfinished rebase holds the index unmerged, and git refuses to check out a branch or make a + * commit against an unmerged index. */ -async function abortInterrupted(repo: { git: SimpleGit }): Promise<'rebase' | 'merge' | null> { +async function abortInterrupted(repo: { git: SimpleGit }): Promise { const interrupted = await interruptedBy(repo) if (!interrupted) { - return null + return } WIKI.logger.warn(`(STORAGE/GIT) Rolling back an unfinished ${interrupted}...`) await repo.git.raw([interrupted, '--abort']) - return interrupted } /** The paths a stopped merge or rebase has left unresolved. */ async function unmergedPaths(repo: { git: SimpleGit }): Promise { const raw = await repo.git.raw(['diff', '--name-only', '--diff-filter=U', '-z']).catch(() => '') - return raw.split('\0').filter((p) => p !== '') + return [...new Set(raw.split('\0').filter((p) => p !== ''))] } /** - * Bring the remote's commits in under the wiki's, resolving whatever collides. + * Move the commits made since `base` onto the remote's branch, the remote winning whatever collides. * - * A rebase stops at the first conflict and waits for a human, and this one has none: the working copy - * is the wiki's own, nobody is looking at it, and the sync that started it returns to a scheduler. So - * a conflicted rebase is rolled back and the pull run again with the remote winning every file that - * changed on both sides — the direction a pull already has here, where what comes back replaces what - * is in the wiki and the version it replaced is still in that page's history. + * `--onto` with `base` spelled out, rather than a pull: what goes on top of the remote is exactly the + * commits the replay just made, whatever the remote's history has done in the meantime — including + * having been rewritten underneath the commit the wiki last synced. * - * `-X ours` reads backwards until you remember which way round a rebase is: the wiki's commits are the - * ones being replayed, so *theirs* is the wiki and *ours* is what was pulled in. + * A rebase stops at the first conflict and waits for a human, and this one has none: nobody is looking + * at this working copy and the sync that started it returns to a scheduler. So a conflicted rebase is + * rolled back and run again with the remote winning every hunk that changed on both sides — the + * direction a sync already has, where what comes back replaces what is in the wiki and the version it + * replaced is still in that page's history. `-X ours` reads backwards until you remember which way + * round a rebase is: the wiki's commits are the ones being replayed, so *theirs* is the wiki and + * *ours* is the remote. * - * What it does not settle is a file one side changed and the other deleted, since there is no version - * of it to prefer. That fails the sync — with the working copy put back rather than left half way - * through a rebase, so the wiki carries on committing while somebody decides which of the two is right. + * What `-X` cannot settle is a file one side changed and the other deleted, there being no hunk to + * prefer. `settleRebase` finishes those by hand, the same way round. A sync that stopped there would + * not be one failed sync but every sync after it, since the queue it failed to publish is still the + * queue the next one starts from. * - * @returns The paths that had to be resolved that way, for the report + * @returns The paths that conflicted, for the report */ -async function pullRebase(repo: Repo, branch: string): Promise { - // -> `--autostash` for the same reason as the rest of this: a write that was staged but never - // committed would otherwise refuse every pull from here on rather than only this one - const options = ['--rebase', '--autostash'] +async function rebaseOnto(repo: Repo, branch: string, base: string): Promise { + const onto = ['--onto', `refs/remotes/origin/${branch}`, base, branch] try { - await repo.git.pull('origin', branch, options) + await repo.git.raw(['rebase', ...onto]) return [] } catch (err: any) { const conflicted = await unmergedPaths(repo) - await abortInterrupted(repo).catch((abortErr: any) => { - WIKI.logger.warn(`(STORAGE/GIT) Could not roll the rebase back: ${abortErr.message}`) - return null - }) - // -> Nothing unmerged means the pull failed for something resolving conflicts cannot fix — an - // unreachable remote, a refused key, a branch that has gone — and the sync should say so + await abortInterrupted(repo) + // -> Nothing unmerged means the rebase failed for something resolving conflicts cannot fix if (conflicted.length < 1) { throw err } @@ -381,77 +540,86 @@ async function pullRebase(repo: Repo, branch: string): Promise { `(STORAGE/GIT) ${conflicted.length} path(s) conflict with origin/${branch}; taking the remote's version...` ) try { - await repo.git.pull('origin', branch, [...options, '-X', 'ours']) + await repo.git.raw(['rebase', '-X', 'ours', ...onto]) } catch (retryErr: any) { - const unsettled = await unmergedPaths(repo) - await abortInterrupted(repo).catch(() => null) - if (unsettled.length < 1) { - throw retryErr - } - throw new Error( - `(STORAGE/GIT) ${unsettled.slice(0, 5).join(', ')}${unsettled.length > 5 ? ', ...' : ''} changed here and were deleted on the remote, or the other way round, which nothing can settle on its own. Nothing has been changed either side. Decide which of the two is right - Purge Local Repository takes the remote's answer, Force Sync in Push mode takes the wiki's.` - ) + await settleRebase(repo, retryErr) } return conflicted } } /** - * Whether this working copy's history and the remote branch's have anything at all in common. + * Finish a stopped rebase by taking the remote's side of every path it stopped on. * - * They always should, and there is one ordinary way they come not to: the content is a database on one - * machine and the working copy is a directory on another, so a container replaced without a volume for - * that directory leaves the wiki with a repository started again from nothing. `prepareRepo` - * initializes it, the first page save commits into it, and what that produces is a second root — the - * same paths as the remote already has, and not one commit in common with them. + * During a rebase stage 2 of an unmerged path is the side being rebased onto, which is the remote: + * where it has the file, its version is checked out, and where it has not, the file goes. A commit + * that leaves nothing staged once that is done had nothing in it the remote did not overrule, and is + * skipped rather than made empty. */ -async function sharesHistoryWith(repo: Repo, branch: string): Promise { - // -> `merge-base` exits non-zero when there is no common commit at all, which simple-git raises - return repo.git - .raw(['merge-base', 'HEAD', `origin/${branch}`]) - .then((out) => out.trim().length > 0) - .catch(() => false) +async function settleRebase(repo: Repo, cause: Error): Promise { + for (let turn = 0; (await interruptedBy(repo)) === 'rebase'; turn++) { + const unmerged = await unmergedPaths(repo) + if (turn >= SETTLE_LIMIT || (turn > 0 && unmerged.length < 1)) { + await abortInterrupted(repo) + throw cause + } + for (const relPath of unmerged) { + const stages = await repo.git.raw(['ls-files', '--unmerged', '-z', '--', relPath]) + if (stages.split('\0').some((entry) => /^\d+ [0-9a-f]+ 2\t/.test(entry))) { + await repo.git.raw(['checkout', '--ours', '--', relPath]) + await repo.git.raw(['add', '--', relPath]) + } else { + await repo.git.raw(['rm', '--cached', '--quiet', '--', relPath]) + await fs.rm(absPathIn(repo.root, relPath), { force: true }) + } + } + const staged = await repo.git.raw(['diff', '--cached', '--quiet']).then( + () => false, + () => true + ) + // -> Stopping again on the next commit raises, and is the next turn of this loop + await repo.git + .raw(staged ? ['-c', 'core.editor=true', 'rebase', '--continue'] : ['rebase', '--skip']) + .catch(() => null) + } } /** - * Take the remote's history up as this working copy's own, keeping the files the wiki has written. + * A file as a commit has it, fetching the commit first if this working copy has never seen it. * - * What a rebase cannot do, and what lets a replaced working copy heal itself instead of waiting for an - * administrator: the two histories are merged, unrelated as they are, and every file that exists on - * both sides is settled in favour of the working copy. That is the opposite of what `pullRebase` does - * with a conflict, and deliberately — these local files were written by page saves made *since* the - * repository was started again, straight out of the wiki's own database, so the remote's copy of one - * of them is by construction the older. The wiki loses nothing it has, the remote's history and every - * file the wiki has not touched since come back, and the push that follows leaves the two in step. - * - * Nothing is written to the wiki. Its database is the half that survived — it is the working copy that - * was lost — and a scheduled job that started creating pages out of a repository it had only just been - * introduced to would be a surprise, most of all where the wiki had deleted them on purpose. Content - * the repository holds and the wiki does not is what `importAll` is for, which is why the report says - * so rather than acting on it. + * Read out of git rather than off the working tree, which on any given instance is wherever its last + * sync left it — or part way through the next one. Not through `withRepo` once the working copy is + * prepared, for the same reason: a read must not wait for a sync's push, and the one thing it may need + * from the network is a fetch, which git does not take the index lock for. */ -async function reattach(repo: Repo, branch: string): Promise { - WIKI.logger.warn( - `(STORAGE/GIT) ${repo.root} has no history in common with origin/${branch}; taking the remote's up...` - ) - try { - await repo.git.raw([ - 'merge', - '--allow-unrelated-histories', - '-X', - 'ours', - '--no-edit', - '-m', - `chore: reconcile the working copy with origin/${branch}`, - `origin/${branch}` - ]) - } catch (err: any) { - await abortInterrupted(repo).catch(() => null) - throw new Error( - `(STORAGE/GIT) This working copy has no history in common with origin/${branch}, and taking the remote's up failed: ${err.message}. Purge Local Repository starts again from the remote's copy.` - ) +async function readBlob( + target: StorageTarget, + commit: string, + relPath: string +): Promise { + const cached = repos.get(target.id) + const repo = + cached?.fingerprint === configFingerprint(target) + ? cached + : await withRepo(target, async (prepared) => prepared) + if (!(await hasCommit(repo, commit))) { + if (!target.config.repoUrl) { + return null + } + // -> One fetch per target however many reads arrive at a cold instance together + let fetching = fetches.get(target.id) + if (!fetching) { + fetching = fetchRemote(repo, target).finally(() => fetches.delete(target.id)) + fetches.set(target.id, fetching) + } + await fetching } - return `The working copy had no history in common with origin/${branch} - which is what a container replaced without a volume for it leaves behind - so the remote's history was taken up and this wiki's own files kept on top of it. Nothing in the wiki itself was changed: run Import Everything if the repository holds content this wiki does not.` + const object = `${commit}:${relPath}` + const exists = await repo.git.raw(['cat-file', '-e', object]).then( + () => true, + () => false + ) + return exists ? ((await repo.git.binaryCatFile(['blob', object])) as Buffer) : null } /** Whether the repository's own ignore rules exclude this path. */ @@ -477,7 +645,7 @@ async function isIgnored(repo: Repo, relPath: string): Promise { * `user.email`, which `prepareRepo` sets from these same two settings. This decides the *author*, * which is the half of a commit that git shows and that would otherwise name the person. */ -async function commitAuthor(target: StorageTarget, actorId?: string): Promise { +async function commitAuthor(target: StorageTarget, actorId?: string | null): Promise { const name = target.config.defaultName || FALLBACK_AUTHOR.name const email = target.config.defaultEmail || FALLBACK_AUTHOR.email if (target.config.alwaysUseDefaultAuthor) { @@ -490,16 +658,20 @@ async function commitAuthor(target: StorageTarget, actorId?: string): Promise { const staged = await repo.git.raw(['diff', '--cached', '--name-only', '--', ...paths]) if (!staged.trim()) { @@ -511,52 +683,14 @@ async function commitPaths( 'commit', '-m', message, - `--author=${await commitAuthor(target, actorId)}`, + `--author=${await commitAuthor(target, by.actorId)}`, + ...(by.date ? [`--date=${by.date.toISOString()}`] : []), '--', ...paths ]) return true } -/** Stage a written file and commit it, unless the repository is told to ignore it. */ -async function stageAndCommit( - repo: Repo, - target: StorageTarget, - relPath: string, - message: string, - actorId?: string -): Promise { - if (await isIgnored(repo, relPath)) { - return - } - await repo.git.raw(['add', '--', relPath]) - await commitPaths(repo, target, [relPath], message, actorId) -} - -/** - * Stage a deletion and commit it. - * - * `git rm` fails on a path git has never heard of, which is an ordinary situation here — a file - * excluded by `.gitignore`, or one deleted before this target was enabled — so the removal is staged - * from the index instead and a path that was not in it simply leaves nothing to commit. - */ -async function removeAndCommit( - repo: Repo, - target: StorageTarget, - relPath: string, - message: string, - actorId?: string -): Promise { - await fs.rm(absPathIn(repo.root, relPath), { force: true }) - try { - await repo.git.raw(['rm', '--cached', '--ignore-unmatch', '--', relPath]) - } catch (err: any) { - WIKI.logger.warn(`(STORAGE/GIT) Could not unstage ${relPath}: ${err.message}`) - return - } - await commitPaths(repo, target, [relPath], message, actorId) -} - /** A page's path as a commit message names it: its locale and where it sits. */ function pageLabel(ref: StoragePageRef): string { return `[${ref.locale}] ${ref.path || '/'}` @@ -725,7 +859,7 @@ async function writeEverything( /** What an import run did, in words. */ function describeImport(summary: ImportSummary | null): string { if (!summary) { - return 'There is nothing in the local repository yet. Run a Force Sync to fetch from the remote first.' + return 'There was nothing to import.' } const parts = [] if (summary.pages > 0 || summary.assets > 0) { @@ -742,6 +876,35 @@ function describeImport(summary: ImportSummary | null): string { return parts.join(' ') } +/** + * Queue a move, or the delete a move amounts to when the site's layout has nowhere to put it now. + */ +async function queueMove( + target: StorageTarget, + kind: 'page' | 'asset', + from: { path: string | null; label: string }, + to: { path: string | null; label: string }, + actorId?: string +): Promise { + if (!from.path || from.path === to.path) { + return + } + await WIKI.models.storage.queueChange( + target, + to.path + ? { + kind, + op: 'move', + path: to.path, + previousPath: from.path, + label: to.label, + previousLabel: from.label, + actorId + } + : { kind, op: 'delete', path: from.path, label: from.label, actorId } + ) +} + /** * Git storage module * @@ -753,33 +916,32 @@ function describeImport(summary: ImportSummary | null): string { * The layout, the front matter and what makes a file a page are all `helpers/storageFiles.ts`, shared * with the disk target. This module is what git adds on top: a commit per change, and a sync. * - * **Local writes, batched network.** A page save commits and returns; nothing waits on a remote. The - * push and the pull happen in `sync`, which the scheduler runs every few minutes and an administrator - * can run on demand. That is why `prepareRepo` and `ensureRemote` are separate: an unreachable remote - * must not be able to make the wiki slow to edit, or fail an upload. + * **The working copy is a cache.** It is a directory on whichever pod happens to be running, and an + * HA set has one per pod — none of them on a volume the others can see, and perhaps none on a volume + * at all. So nothing is kept only there. A change made in the wiki is queued in the database + * (`storageOutbox`) by the instance that made it, and the commit the wiki and the remote last agreed on + * is recorded there too (`storageSyncState`). A sync, on whichever instance runs it, puts its own + * working copy at that commit, replays the queue on top as one commit per change, and publishes. Two + * instances never sync the same target at once (`exclusive`), and an instance whose copy is empty, + * stale or wedged simply rebuilds it. + * + * **Nothing waits on git but the sync.** A page save is a row inserted; the commit, the push and the + * pull all happen in `sync`, which the scheduler runs every few minutes and an administrator can run on + * demand. An unreachable remote cannot make the wiki slow to edit, or fail an upload. * * **A pull is authoritative.** What it brings in is applied to the wiki, replacing what is there — and * a commit that deleted a file deletes the page or the asset here too, which is the whole point of * pointing a wiki at a repository other people push to. It also means push access to the remote is * effectively write access to the wiki, which is worth knowing before configuring one. It is also how * a file that changed on both sides is settled: the remote wins it, since nobody is here to be asked. - * - * **A sync always leaves the working copy usable**, which is what `abortInterrupted` is for. A stopped - * rebase holds the index unmerged and git then refuses every write against it, so a conflict nobody - * resolves does not cost one sync — it costs every commit the wiki makes afterwards. - * - * **A working copy started again from nothing re-attaches itself.** Losing one is ordinary rather than - * exceptional: it is a directory in a container, the content is a database somewhere else, and an - * upgrade that replaces the container without a volume for it takes it with it. What the wiki then has - * is the remote's files under a root commit of its own, so a sync that finds no commit in common takes - * the remote's history up instead of rebasing onto it — `reattach`. - * - * Everything runs through `withRepo`, one operation at a time per target: git locks its index for the - * length of a write, so two concurrent uploads would otherwise have one of them fail outright. + * And it is applied once: the diff is taken from the commit last agreed on, which every instance + * reads from the same row. */ const gitStorage: StorageModule = { canStore(target, ref) { - return WIKI.models.storage.pathPrefixFor(target.siteId, ref.locale) !== null + return ( + acceptsWrites(target) && WIKI.models.storage.pathPrefixFor(target.siteId, ref.locale) !== null + ) }, async putAsset(target, ref, data) { @@ -789,329 +951,448 @@ const gitStorage: StorageModule = { `(STORAGE/GIT) ${target.title} has no path for ${ref.locale} content, so ${ref.fileName} cannot be stored there.` ) } - await withRepo(target, async (repo) => { - const filePath = absPathIn(repo.root, relPath) - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, data) - await stageAndCommit(repo, target, relPath, `docs: upload ${assetLabel(ref)}`, ref.actorId) + await WIKI.models.storage.queueChange(target, { + kind: 'asset', + op: 'put', + path: relPath, + label: assetLabel(ref), + data, + actorId: ref.actorId }) }, + /** + * The bytes of an asset, from the queue if they have not been published yet and from the commit + * last synced if they have. + * + * An asset whose only home is this target has its bytes in the queue until a sync has pushed them, + * and it may have been moved since — which queues no bytes of its own — so a move is followed back + * to wherever the file was before it. + */ async getAsset(target, ref) { - const relPath = assetRelPath(target, ref) + let relPath = assetRelPath(target, ref) if (!relPath) { return null } - // -> Read straight off the working copy rather than out of git: what the wiki serves is the - // current state of the branch, which is exactly what is checked out - try { - return await fs.readFile(absPathIn(repoDir(target), relPath)) - } catch (err: any) { - if (err.code !== 'ENOENT') { - throw err + let beforeId: number | undefined + for (let hop = 0; hop < MAX_MOVE_HOPS; hop++) { + const change = await WIKI.models.storage.latestQueuedAt(target, relPath, beforeId) + if (!change) { + break } - return null + if (change.op === 'put') { + return WIKI.models.storage.queuedData(change.id) + } + if (change.op === 'delete' || !change.previousPath) { + return null + } + relPath = change.previousPath + beforeId = change.id } + const commit = await syncedCommit(target) + return commit ? readBlob(target, commit, relPath) : null }, async deleteAsset(target, ref) { const relPath = assetRelPath(target, ref) - if (!relPath) { + if (!relPath || !acceptsWrites(target)) { return } - await withRepo(target, (repo) => - removeAndCommit(repo, target, relPath, `docs: delete ${assetLabel(ref)}`, ref.actorId) - ) + await WIKI.models.storage.queueChange(target, { + kind: 'asset', + op: 'delete', + path: relPath, + label: assetLabel(ref), + actorId: ref.actorId + }) }, async moveAsset(target, ref, previous) { - const from = assetRelPath(target, { ...ref, ...previous }) - const to = assetRelPath(target, ref) - await withRepo(target, async (repo) => { - const outcome = await moveStored(repo.root, from, to) - if (outcome === 'nothing') { - return - } - const paths = outcome === 'moved' ? [from!, to!] : [from!] - for (const relPath of paths) { - await repo.git.add(['-A', '--', relPath]) - } - await commitPaths( - repo, - target, - paths, - outcome === 'moved' - ? `docs: rename ${assetLabel({ ...ref, ...previous })} to ${assetLabel(ref)}` - : `docs: delete ${assetLabel({ ...ref, ...previous })}`, - ref.actorId - ) - }) + if (!acceptsWrites(target)) { + return + } + await queueMove( + target, + 'asset', + { + path: assetRelPath(target, { ...ref, ...previous }), + label: assetLabel({ ...ref, ...previous }) + }, + { path: assetRelPath(target, ref), label: assetLabel(ref) }, + ref.actorId + ) }, async putPage(target, ref, page) { const relPath = pageRelPath(target, ref) - if (!relPath) { + if (!relPath || !acceptsWrites(target)) { return } - await withRepo(target, async (repo) => { - const filePath = absPathIn(repo.root, relPath) - // -> Which of the two verbs the commit gets. Read before the write, since afterwards every page - // looks like one that was already there. - const existed = await fs - .access(filePath) - .then(() => true) - .catch(() => false) - await fs.mkdir(path.dirname(filePath), { recursive: true }) - await fs.writeFile(filePath, serializePage(ref, page), 'utf8') - await stageAndCommit( - repo, - target, - relPath, - `docs: ${existed ? 'update' : 'create'} ${pageLabel(ref)}`, - ref.actorId - ) + await WIKI.models.storage.queueChange(target, { + kind: 'page', + op: 'put', + path: relPath, + label: pageLabel(ref), + data: Buffer.from(serializePage(ref, page), 'utf8'), + actorId: ref.actorId }) }, async deletePage(target, ref) { const relPath = pageRelPath(target, ref) - if (!relPath) { + if (!relPath || !acceptsWrites(target)) { return } - await withRepo(target, (repo) => - removeAndCommit(repo, target, relPath, `docs: delete ${pageLabel(ref)}`, ref.actorId) - ) + await WIKI.models.storage.queueChange(target, { + kind: 'page', + op: 'delete', + path: relPath, + label: pageLabel(ref), + actorId: ref.actorId + }) }, async movePage(target, ref, previous) { - const from = pageRelPath(target, { ...ref, ...previous }) - const to = pageRelPath(target, ref) - await withRepo(target, async (repo) => { - const outcome = await moveStored(repo.root, from, to) - if (outcome === 'nothing') { - return - } - const paths = outcome === 'moved' ? [from!, to!] : [from!] - for (const relPath of paths) { - await repo.git.add(['-A', '--', relPath]) - } - await commitPaths( - repo, - target, - paths, - outcome === 'moved' - ? `docs: rename ${pageLabel({ ...ref, ...previous })} to ${pageLabel(ref)}` - : `docs: delete ${pageLabel({ ...ref, ...previous })}`, - ref.actorId - ) - }) + if (!acceptsWrites(target)) { + return + } + await queueMove( + target, + 'page', + { + path: pageRelPath(target, { ...ref, ...previous }), + label: pageLabel({ ...ref, ...previous }) + }, + { path: pageRelPath(target, ref), label: pageLabel(ref) }, + ref.actorId + ) }, /** - * Pull from the remote, push to it, and apply what came back. + * Publish what the wiki has queued, and apply what the remote has that the wiki has not. * - * The direction is the target's `syncMode`, and it decides which half runs: `push` never takes - * anything in and force-pushes, so the wiki wins; `pull` never sends anything, so the remote does; - * `sync` does both, rebasing the wiki's commits on top of what it pulled — and resolving whatever - * that conflicts on rather than stopping half way through it. See `pullRebase`. - * - * Whatever a pull brought in is then applied to the wiki — created, replaced, or deleted. That is - * done from a `--name-status` diff between the commit the branch was on before and the one it is on - * now, rather than by walking the tree: a sync runs every few minutes, and reading every file in the - * repository each time to find the two that changed would be absurd. + * The direction is the target's `syncMode`: `push` never takes anything in and force-pushes, so the + * wiki wins; `pull` never sends anything, so the remote does; `sync` does both, rebasing the wiki's + * commits on top of what it fetched — and resolving whatever that conflicts on rather than stopping + * half way through it. See `rebaseOnto`. */ async sync(target: StorageTarget, actorId: string): Promise { - const mode = target.config.syncMode || 'sync' - const branch = target.config.branch || 'main' - return withRepo(target, async (repo) => { - if (!target.config.repoUrl) { - return '(STORAGE/GIT) No repository URI is configured, so there is nothing to sync with. Commits are being made locally.' - } - // -> Before anything else touches the repository, since git refuses to move a branch or make a - // commit while an earlier operation is unfinished — `ensureRemote`'s checkout is the first - // thing to fail, which is why that is what a wedged working copy reports - const recovered = await abortInterrupted(repo) - const { onRemote } = await ensureRemote(repo, target) - - const before = await repo.git.revparse(['HEAD']).catch(() => null) - const parts: string[] = [] - - // -> Nothing to pull from a branch the remote does not have yet; the push below creates it - let conflicted: string[] = [] - let reattached: string | null = null - if (onRemote && before && !(await sharesHistoryWith(repo, branch))) { - /* - In every mode, including the two that would otherwise not go near the remote's history. A - `push` that forced this working copy onto the remote would replace a wiki's whole repository - with the handful of pages saved since the copy was started again — the mode says the wiki is - the authority, and a working copy that has just been created is not the wiki. - */ - reattached = await reattach(repo, branch) - } else if (mode !== 'push' && onRemote) { - WIKI.logger.info(`(STORAGE/GIT) Pulling from origin/${branch}...`) - conflicted = await pullRebase(repo, branch) - } - if (mode !== 'pull') { - WIKI.logger.info(`(STORAGE/GIT) Pushing to origin/${branch}...`) - // -> `--force` only in push mode, which is the mode that says the wiki is the authority - await repo.git.push( - 'origin', - branch, - mode === 'push' ? ['--signed=if-asked', '--force'] : ['--signed=if-asked'] - ) - } - - if (reattached) { - // -> Emphatically not `applyIncoming`: every file on the remote arrived in this working copy - // just now, so the diff is the entire repository and applying it would rewrite every page - // in the wiki from a copy of itself - parts.push(reattached) - } else if (mode !== 'push' && onRemote) { - const after = await repo.git.revparse(['HEAD']).catch(() => null) - if (!after) { - return 'Synced. The repository has no commits yet.' - } - parts.push(await applyIncoming(repo, target, before, after, actorId)) - } else if (mode === 'pull') { - parts.push( - 'Synced. The remote does not have this branch yet, so there was nothing to pull.' - ) - } else { - parts.push('Pushed to the remote.') - } - if (recovered) { - parts.push(`An unfinished ${recovered} left by an earlier sync was rolled back first.`) - } - if (conflicted.length > 0) { - parts.push( - `${conflicted.length} file(s) had changed both here and on the remote, and the remote's version won. A page it replaced keeps its previous version in its history; a file has none.` - ) - } - return parts.join(' ') - }) + return exclusive( + target, + 'Another instance is syncing this target right now; what the wiki has queued will be published by that sync or the next one.', + (repo) => publish(repo, target, actorId) + ) }, /** - * Write and commit everything this target should be holding but has never been given. + * Write and commit everything this target should be holding but has never been given, then sync. * * The way in for content that predates the target: a wiki that ran for a year before git was * enabled has a repository with nothing in it, and nothing in the ordinary course of things ever * goes back for those pages. One commit, by the administrator who asked for it, since it is their - * action and not a hundred authors' edits. + * action and not a hundred authors' edits — made as part of a sync, since a commit made anywhere + * else would be in one pod's working copy and nowhere else. */ async syncUntracked(target: StorageTarget, actorId: string): Promise { - return withRepo(target, async (repo) => { - const counts = await writeEverything(repo, target) - await repo.git.add(['-A', '--', '.']) - const committed = await commitPaths( - repo, - target, - ['.'], - 'docs: add all untracked content', - actorId - ) - WIKI.logger.info( - `(STORAGE/GIT) Wrote ${counts.pages} page(s) and ${counts.assets} asset(s) to ${repo.root} [ OK ]` - ) - const parts = [ - committed - ? `Committed the untracked part of ${counts.pages} page(s) and ${counts.assets} asset(s).` - : `Wrote ${counts.pages} page(s) and ${counts.assets} asset(s); all of it was already tracked.` - ] - if (counts.unreadable > 0) { - parts.push(`${counts.unreadable} asset(s) could not be read and were skipped.`) - } - if (counts.unstored > 0) { - const { primaryLocale } = WIKI.models.storage.pathLayoutFor(target.siteId) - parts.push( - `${counts.unstored} item(s) are not in the ${primaryLocale} locale, which is the only one this site stores.` - ) - } - return parts.join(' ') - }) - }, - - /** - * Take everything in the working copy into the wiki, whatever the last commit did. - * - * For a repository that already had content before this target existed: the sync only ever looks at - * what changed between two commits, so a repository cloned with a thousand files in it has none of - * them in the wiki. The repository wins every collision, consistent with what a pull does — this is - * the same direction, applied to everything at once instead of to a diff. - */ - async importAll(target: StorageTarget, actorId: string): Promise { - return withRepo(target, async (repo) => - describeImport(await importTree({ target, root: repo.root, actorId, overwrite: true })) + if (!acceptsWrites(target)) { + return 'This target is set to Pull only, so nothing the wiki holds is written to the repository.' + } + return exclusive( + target, + 'Another instance is syncing this target right now. Try again once it has finished.', + (repo) => publish(repo, target, actorId, { addUntracked: true }) ) }, /** - * Throw the working copy away and take it again from the remote. + * Take everything the repository holds into the wiki, whatever the last commit did. * - * The answer to a working copy git can no longer make sense of — unrelated histories, a rebase that - * cannot be finished, an index that will not unlock. Nothing about the remote changes and nothing is - * committed, so the cost is any commit that only existed here. + * For a repository that already had content before this target existed: the sync only ever applies + * what changed between two commits, and the first sync applies nothing at all, so a repository with + * a thousand files in it has none of them in the wiki. The repository wins every collision, + * consistent with what a pull does — except over a file the wiki has changed since and not yet + * published, which is newer here than there. + * + * Read out of the commit last synced rather than off the working tree, which on this instance may be + * anything from empty to part way through the next sync. + */ + async importAll(target: StorageTarget, actorId: string): Promise { + return withRepo(target, async (repo) => { + const commit = await syncedCommit(target) + if (!commit) { + return 'This target has not synced with its repository yet, so there is nothing to import. Run a Force Sync first.' + } + if (!(await hasCommit(repo, commit)) && target.config.repoUrl) { + await fetchRemote(repo, target) + } + const unpublished = new Set( + (await WIKI.models.storage.queuedChanges(target)).flatMap((change) => + change.previousPath ? [change.path, change.previousPath] : [change.path] + ) + ) + const files: StoredFile[] = ( + await repo.git.raw(['ls-tree', '-r', '-z', '--name-only', commit]) + ) + .split('\0') + .filter((relPath) => relPath !== '' && !unpublished.has(relPath)) + .map((relPath) => ({ filePath: relPath, segments: relPath.split('/') })) + .filter(({ segments }) => !isIgnoredPath(segments)) + return describeImport( + await importTree({ + target, + root: repo.root, + actorId, + overwrite: true, + files, + readFile: async (relPath) => + (await repo.git.binaryCatFile(['blob', `${commit}:${relPath}`])) as Buffer + }) + ) + }) + }, + + /** + * Throw this instance's working copy away. + * + * Nothing is lost by it when there is a remote: what has not been published is in the queue, and the + * next sync on this instance builds the working copy again from the remote. It is for a copy git can + * no longer make sense of at all — and is about the instance that answered the request, which in an + * HA set is whichever one the load balancer picked. */ async purge(target: StorageTarget): Promise { const root = repoDir(target) return withRepo(target, async () => { - WIKI.logger.info(`(STORAGE/GIT) Purging the local repository at ${root}...`) + WIKI.logger.info(`(STORAGE/GIT) Purging the working copy at ${root}...`) await fs.rm(root, { recursive: true, force: true }) // -> Dropped rather than reused: it holds a SimpleGit bound to a directory that no longer // exists, and the next operation is what sets the replacement up repos.delete(target.id) - if (!target.config.repoUrl) { - return 'The local repository has been emptied. It will be initialized again on the next change.' - } - const repo = await prepareRepo(target) - repos.set(target.id, repo) - await ensureRemote(repo, target) - return 'The local repository has been emptied and taken again from the remote. Run Import Everything if the wiki should now say what it holds.' + return target.config.repoUrl + ? "This instance's working copy has been removed. The next sync here takes it again from the remote, with the changes the wiki has queued on top." + : 'The local repository has been emptied, history and all. It will be initialized again on the next sync.' }) } } /** - * Apply what a pull brought in to the wiki. + * One sync, on this instance's working copy, with no other instance syncing the same target. * - * Split out of `sync` because it is the interesting half and reads as its own thing: a diff, and then - * two lists — what to take in and what to take out. A file that arrived or changed is imported with - * the repository winning; one that went is deleted. A rename is both, in that order, which is what - * moves a page rather than losing its history to a delete and a create. + * In order: put the working copy at the commit last agreed on, commit the queue on top, settle with + * the remote's branch (rebase, or force, or neither, by mode), push, apply what the remote brought, + * and only then drop what was published from the queue and record the new commit — both at once. An + * instance that dies anywhere before that last step leaves the queue and the record as they were, so + * the next sync, on whichever instance, does it all again: the commits it replays are already on the + * remote and the rebase drops them as such, and the changes it applies are applied the same way twice. + * + * **The first sync with a remote applies nothing.** With no commit agreed on yet — a new target, one + * pointed at another repository or branch, or one whose commit the remote no longer has because + * somebody rewrote its history — the remote's branch is taken up as it is and the queue committed on + * top. There is nothing to diff it against, and a scheduled job that started rewriting pages out of a + * repository it had only just been introduced to would be a surprise, most of all where the wiki had + * deleted them on purpose. Import Everything is what takes the repository's content in. + */ +async function publish( + repo: Repo, + target: StorageTarget, + actorId: string, + { addUntracked = false }: { addUntracked?: boolean } = {} +): Promise { + const mode = modeOf(target) + const branch = branchOf(target) + const remote = target.config.repoUrl || '' + const known = await syncedCommit(target) + const queue = await WIKI.models.storage.queuedChanges(target) + const toReplay = acceptsWrites(target) ? queue : [] + const parts: string[] = [] + + if (!remote) { + const base = + known && (await hasCommit(repo, known)) + ? known + : await resolveRef(repo, `refs/heads/${branch}`) + await checkoutAt(repo, branch, base) + const published = await replay(repo, target, toReplay) + if (addUntracked) { + parts.push(await commitUntracked(repo, target, actorId)) + } + const head = await resolveRef(repo, 'HEAD') + await WIKI.models.storage.settleSync(target, { + published, + state: head ? { remote, branch, commit: head } : null + }) + parts.unshift( + `No repository URI is configured, so the repository is this instance's alone: ${published.length} queued change(s) were committed to it and nothing was pushed.` + ) + return parts.join(' ') + } + + const remoteHead = await fetchRemote(repo, target) + const base = known && (await hasCommit(repo, known)) ? known : remoteHead + const attaching = base !== null && base !== known + await checkoutAt(repo, branch, base) + + const published = await replay(repo, target, toReplay) + if (addUntracked) { + parts.push(await commitUntracked(repo, target, actorId)) + } + const before = await resolveRef(repo, 'HEAD') + + let conflicted: string[] = [] + if (mode === 'sync' && remoteHead && base && base !== remoteHead) { + WIKI.logger.info(`(STORAGE/GIT) Rebasing onto origin/${branch}...`) + conflicted = await rebaseOnto(repo, branch, base) + } else if (mode === 'pull' && remoteHead && base !== remoteHead) { + await checkoutAt(repo, branch, remoteHead) + } + const head = await resolveRef(repo, 'HEAD') + + if (mode !== 'pull' && head && head !== remoteHead) { + WIKI.logger.info(`(STORAGE/GIT) Pushing to origin/${branch}...`) + // -> `--force` only in push mode, which is the mode that says the wiki is the authority + await repo.git.push( + 'origin', + branch, + mode === 'push' ? ['--signed=if-asked', '--force'] : ['--signed=if-asked'] + ) + } + + const changes = + !attaching && mode !== 'push' && before && head && before !== head + ? await changedPaths(repo, before, head) + : [] + + // -> What is still queued and was not replayed: changes made in the wiki while this ran, in any mode + // but Pull only, and changes made before a target was switched to it, in that one + const replayed = new Set(published) + const pending = (await WIKI.models.storage.queuedChanges(target)).filter( + (change) => !replayed.has(change.id) + ) + const touched = (change: StorageQueuedChange) => + change.previousPath ? [change.path, change.previousPath] : [change.path] + const discarded: number[] = [] + let held = new Set() + if (mode === 'pull') { + // -> The remote is the only author in this mode, so a queued change to a file it has changed since + // is overruled rather than held on to until the mode changes back + const incoming = new Set( + changes.flatMap((change) => + [change.segments, change.previousSegments].flatMap((s) => (s ? [s.join('/')] : [])) + ) + ) + for (const change of pending) { + if (touched(change).some((relPath) => incoming.has(relPath))) { + discarded.push(change.id) + } + } + } else { + // -> Newer than anything the remote has, so the wiki keeps them and the next sync publishes them + held = new Set(pending.flatMap(touched)) + } + + if (attaching) { + parts.unshift( + `This target had no commit agreed with origin/${branch} - it has not synced with it before, or the remote's history was rewritten since - so the remote's branch was taken up as it stands${published.length > 0 ? ` and ${published.length} queued change(s) committed on top of it` : ''}. Nothing in the wiki itself was changed: run Import Everything if the repository holds content this wiki does not.` + ) + } else if (changes.length > 0) { + parts.unshift(await applyIncoming(repo, target, changes, held, actorId)) + } else if (mode === 'push') { + parts.unshift(`Pushed ${published.length} queued change(s) to the remote.`) + } else if (!remoteHead) { + parts.unshift( + mode === 'pull' + ? 'Synced. The remote does not have this branch yet, so there was nothing to pull.' + : `Synced. The remote did not have this branch yet, and ${published.length} queued change(s) created it.` + ) + } else { + parts.unshift( + `Synced. Nothing had changed on the remote${mode === 'pull' ? '' : `, and ${published.length} queued change(s) were published`}.` + ) + } + if (conflicted.length > 0) { + parts.push( + `${conflicted.length} file(s) had changed both here and on the remote, and the remote's version won. A page it replaced keeps its previous version in its history; a file has none.` + ) + } + + await WIKI.models.storage.settleSync(target, { + published: [...published, ...discarded], + state: head ? { remote, branch, commit: head } : null + }) + return parts.join(' ') +} + +/** + * Write everything this target should be holding into the working copy, and commit what was missing. + */ +async function commitUntracked( + repo: Repo, + target: StorageTarget, + actorId: string +): Promise { + const counts = await writeEverything(repo, target) + await repo.git.add(['-A', '--', '.']) + const committed = await commitPaths(repo, target, ['.'], 'docs: add all untracked content', { + actorId + }) + WIKI.logger.info( + `(STORAGE/GIT) Wrote ${counts.pages} page(s) and ${counts.assets} asset(s) to ${repo.root} [ OK ]` + ) + const parts = [ + committed + ? `Committed the untracked part of ${counts.pages} page(s) and ${counts.assets} asset(s).` + : `Wrote ${counts.pages} page(s) and ${counts.assets} asset(s); all of it was already tracked.` + ] + if (counts.unreadable > 0) { + parts.push(`${counts.unreadable} asset(s) could not be read and were skipped.`) + } + if (counts.unstored > 0) { + const { primaryLocale } = WIKI.models.storage.pathLayoutFor(target.siteId) + parts.push( + `${counts.unstored} item(s) are not in the ${primaryLocale} locale, which is the only one this site stores.` + ) + } + return parts.join(' ') +} + +/** + * Apply what the remote changed to the wiki. + * + * A file that arrived or changed is imported with the repository winning; one that went is deleted. A + * rename is both, in that order, which is what moves a page rather than losing its history to a + * delete and a create. + * + * @param held Paths the wiki changed while the sync ran. The wiki's version is the newer one, and the + * next sync publishes it over the remote's, so the remote's is not applied over it in the meantime. */ async function applyIncoming( repo: Repo, target: StorageTarget, - before: string | null, - after: string, + changes: DiffEntry[], + held: Set, actorId: string ): Promise { - // -> Nothing came back, which is the ordinary outcome of a sync and worth saying plainly - if (before === after) { - return 'Synced. Nothing had changed on the remote.' - } - - let files: StoredFile[] | null + const files: StoredFile[] = [] const removals: string[][] = [] - if (!before) { - // -> Nothing to diff against: the branch had no commits here at all, so everything in it is new - files = await walkStored(repo.root) - } else { - const changes = await changedPaths(repo, before, after) - files = [] - for (const change of changes) { - if (change.status === 'D') { - removals.push(change.segments) - continue - } - if (change.status === 'R' && change.previousSegments) { - removals.push(change.previousSegments) - } - files.push({ - filePath: absPathIn(repo.root, change.segments.join('/')), - segments: change.segments - }) + let heldBack = 0 + for (const change of changes) { + const paths = [change.segments, change.previousSegments].flatMap((s) => + s ? [s.join('/')] : [] + ) + if (paths.some((relPath) => held.has(relPath))) { + heldBack++ + continue } + if (change.status === 'D') { + removals.push(change.segments) + continue + } + if (change.status === 'R' && change.previousSegments) { + removals.push(change.previousSegments) + } + files.push({ + filePath: absPathIn(repo.root, change.segments.join('/')), + segments: change.segments + }) } const summary = await importTree({ @@ -1126,8 +1407,7 @@ async function applyIncoming( let deletedAssets = 0 for (const segments of removals) { try { - // -> The file is already gone from this working copy, and deleting it again would queue behind - // the sync that is running this — see `storage.importingFrom` + // -> Not queued back for this target: the file is already gone from the repository const removed = await WIKI.models.storage.importingFrom(target, () => removeFromWiki(target, segments, actorId) ) @@ -1153,6 +1433,11 @@ async function applyIncoming( if (summary && summary.failed > 0) { parts.push(`${summary.failed} could not be imported - see the server log.`) } + if (heldBack > 0) { + parts.push( + `${heldBack} file(s) the remote changed were changed in the wiki too while this ran; the wiki's version is published next.` + ) + } if (parts.length === 1) { parts.push('Nothing the remote changed affected this wiki.') } diff --git a/backend/tasks/simple/sync-storage-targets.ts b/backend/tasks/simple/sync-storage-targets.ts index 504a33334..05fc42218 100644 --- a/backend/tasks/simple/sync-storage-targets.ts +++ b/backend/tasks/simple/sync-storage-targets.ts @@ -21,9 +21,11 @@ import type { TaskContext } from '../../core/scheduler.ts' * must not stop the rest of them syncing. `executeAction` records the outcome on the target itself, * so a failure shows up on its Status card in the admin area rather than only in this log. * - * **One instance at a time.** The scheduler hands a job to a single instance, which is the one whose - * working copy is synced. Every instance in a high-availability set keeps its own, so they each fall - * in step at their own turn rather than fighting over one repository. + * **Any instance will do.** The scheduler hands each tick to whichever instance claims it, and a + * target's working copy on that instance is only a cache: what the wiki has not yet published is + * queued in the database, and so is the commit it last agreed with the remote on, so a sync rebuilds + * the working copy from those wherever it runs. Two instances never sync the same target at once — one + * that finds another already at it reports so and leaves it be. */ export async function task(_payload: unknown, { signal }: TaskContext): Promise { const syncable = await WIKI.models.storage.syncableTargets() diff --git a/dev/chart/README.md b/dev/chart/README.md index cdef73f7f..125bc9b68 100644 --- a/dev/chart/README.md +++ b/dev/chart/README.md @@ -107,8 +107,11 @@ included, so no sticky sessions are needed. The data directory (`/wiki/data`) is per replica unless `persistence` points every replica at one ReadWriteMany claim. That does not matter for the caches it normally holds, which are rebuilt from -the database. It does matter for a **Local Disk** or **Git** storage target left at its default -location under `data/`, which would otherwise be a separate copy on each replica. +the database, nor for a **Git** storage target with a remote: each replica's working copy under +`data/repo` is a cache too, rebuilt from the remote and from the changes queued in the database, so +any replica can sync and none of them needs a volume. It does matter for a **Local Disk** storage +target left at its default location under `data/`, and for a Git target with no remote configured, +either of which would otherwise be a separate copy on each replica. With one replica on a ReadWriteOnce claim, set `strategy.type: Recreate`. Otherwise a rolling update's new pod may land on a node the volume cannot be attached to. diff --git a/dev/chart/values.yaml b/dev/chart/values.yaml index 3f8a123f1..a96da1b99 100644 --- a/dev/chart/values.yaml +++ b/dev/chart/values.yaml @@ -184,16 +184,17 @@ volumeMounts: [] # subPath: known_hosts # readOnly: true -# The wiki's data directory, /wiki/data. Holds the caches (icons, served files, blocks), which rebuild -# themselves from the database, plus the default locations of the Local Disk (`data/content`) and Git -# (`data/repo`) storage targets. An emptyDir is enough unless one of those two is in use. +# The wiki's data directory, /wiki/data. Holds the caches (icons, served files, blocks, and a Git +# storage target's working copy), which rebuild themselves from the database and the remote, plus the +# default location of the Local Disk storage target (`data/content`). An emptyDir is enough unless +# that target is in use, or a Git target with no remote configured. persistence: enabled: false # Use an existing claim instead of creating one. existingClaim: '' # `-` for the cluster's default class, empty to leave it unset. storageClass: '' - # ReadWriteMany is what lets several replicas share one Local Disk or Git storage target. + # ReadWriteMany is what lets several replicas share one Local Disk storage target. accessModes: - ReadWriteOnce size: 10Gi