From 0385c375c9cd45ceb2051c37f9be7236e6a8529b Mon Sep 17 00:00:00 2001 From: NGPixel Date: Tue, 15 Sep 2026 20:09:12 -0400 Subject: [PATCH] feat: view page backlinks --- backend/api/pages.ts | 192 + backend/api/schemas/page.ts | 2 + backend/api/schemas/site.ts | 5 + backend/api/system.ts | 58 + .../20260915225306_scarlett/migration.sql | 22 + .../20260915225306_scarlett/snapshot.json | 6783 +++++++++++++++++ backend/db/schema.ts | 99 +- backend/helpers/pageLinks.ts | 272 + backend/locales/en.json | 17 + backend/models/auditLog.ts | 1 + backend/models/index.ts | 2 + backend/models/pageLinks.ts | 416 + backend/models/pages.ts | 56 +- backend/models/rendering.ts | 15 +- backend/models/sites.ts | 6 + backend/models/tree.ts | 11 + backend/tasks/workers/rebuild-page-links.ts | 106 + frontend/src/components/PageLinks.vue | 352 + .../src/components/PagePropertiesDialog.vue | 16 + frontend/src/components/PageViewTabs.vue | 68 +- frontend/src/pages/AdminGeneral.vue | 32 + frontend/src/pages/AdminUtilities.vue | 54 + frontend/src/pages/Index.vue | 96 +- frontend/src/stores/page.js | 5 + frontend/src/stores/site.js | 7 + 25 files changed, 8651 insertions(+), 42 deletions(-) create mode 100644 backend/db/migrations/20260915225306_scarlett/migration.sql create mode 100644 backend/db/migrations/20260915225306_scarlett/snapshot.json create mode 100644 backend/helpers/pageLinks.ts create mode 100644 backend/models/pageLinks.ts create mode 100644 backend/tasks/workers/rebuild-page-links.ts create mode 100644 frontend/src/components/PageLinks.vue diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 4689055f3..6a87f5729 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -1614,6 +1614,198 @@ async function routes(app: FastifyInstance) { } ) + /** + * LIST BACKLINKS + * + * What links to this page. + * + * Asked by the page's ADDRESS rather than by its id, which is the whole reason the table stores an + * address: a link says where it points, so the pages pointing at one that has moved still point at + * where it was. The two short links are folded in by id and by alias, since those two survive a move + * by design and belong in the same list. + */ + app.get<{ Params: { siteId: string; pageId: string } }>( + '/sites/:siteId/pages/:pageId/backlinks', + { + /* + No route-level `permissions`: reading a page's backlinks is `read:pages` ON THAT PAGE, and on + every page in the answer — see below. + */ + schema: { + summary: 'List the pages linking to a page', + description: + "Every page of this instance whose content, redirection target or sidebar relations point at this one, with the href each of them wrote.\n\nAnswers 404 where the site has `features.backlinks` off, which is the switch under General → Features. A page that has turned its own Links tab off with `allowBacklinks` still answers here — that flag hides the tab, and what it hides is not a secret.\n\nFiltered by what the caller may read, one page at a time: a backlink names a page, its title and where it sits, so listing one the caller has no `read:pages` rule for would hand them the existence of a page they cannot open. A link written on another SITE of this instance is included, since a move here breaks it just the same.\n\nNote that links pointing at a page's OLD path are not listed here — after a move they address a path this page no longer has, which is what makes them findable as the links that move broke.", + tags: ['Pages'], + params: pageIdParam, + response: { + 200: { + description: 'Pages linking here', + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + siteId: { type: 'string', format: 'uuid' }, + locale: { type: 'string' }, + path: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + icon: { + type: 'string', + description: + "The page's own icon as an Iconify reference, or empty where it has none." + }, + url: { + type: 'string', + description: 'Where that page is, as a path on its own site.' + }, + hostname: { + type: 'string', + nullable: true, + description: + 'The host its site answers on, for a page on another site of this instance. Null when it is on the site being read, or on the catch-all site.' + }, + href: { + type: 'string', + description: 'The link as that page writes it, which is what a repair would edit.' + }, + kind: { + type: 'string', + description: 'How the link addresses this page: by path, by alias or by id.' + } + } + } + } + } + } + }, + async (req, reply) => { + // -> The site-wide switch, as `requireBuiltInPage` checks the comments one: with the feature + // off there is nothing here to answer, however the page itself is set + if (!WIKI.models.pageLinks.isAllowed(req.params.siteId)) { + return reply.notFound('This site does not show what links to a page.') + } + const page = await loadReadablePage(req, req.params.siteId, req.params.pageId) + if (!page) { + return reply.notFound('This page does not exist.') + } + // -> As the history route does: a page withheld until its password is entered withholds what is + // derived from it too, and its link graph names it in one direction and reads its content in + // the other + if (page.isLocked) { + return reply.forbidden('This page is password protected.') + } + + const backlinks = await WIKI.models.pageLinks.backlinksFor( + { siteId: req.params.siteId, locale: page.locale, path: page.path }, + { pageId: page.id, alias: page.alias } + ) + + return backlinks + .filter((link) => + mayOnPage(req, 'read:pages', { + siteId: link.siteId, + locale: link.locale, + path: link.path, + tags: link.tags + }) + ) + .map((link) => ({ + id: link.pageId, + siteId: link.siteId, + locale: link.locale, + path: link.path, + title: link.title, + description: link.description ?? '', + icon: link.icon ?? '', + url: link.url, + hostname: link.hostname, + href: link.href, + kind: link.kind + })) + } + ) + + /** + * LIST OUTBOUND LINKS + * + * What this page links to, and whether anything is there. + * + * The red-link question. Resolution is a join rather than a stored flag, so a page created at a path + * somebody had already linked to makes that link good without anything having to notice. + */ + app.get<{ Params: { siteId: string; pageId: string } }>( + '/sites/:siteId/pages/:pageId/links', + { + // -> No route-level `permissions`: `read:pages` on the page itself, as above + schema: { + summary: 'List the links written on a page', + description: + "Every link this page's content, redirection target or sidebar relations point at, each with the href as written and what it resolves to. `exists` is false for a link addressing a page that is not there — the state a wiki draws as a red link.\n\nLinks leaving the wiki are not recorded and are not listed. Uploaded files are listed but never resolved: they are tracked so a moved file can be traced back to what pointed at it, and answering whether one exists is a different lookup.", + tags: ['Pages'], + params: pageIdParam, + response: { + 200: { + description: 'Links written on this page', + type: 'array', + items: { + type: 'object', + properties: { + href: { type: 'string' }, + kind: { type: 'string' }, + targetSiteId: { type: 'string', format: 'uuid' }, + targetLocale: { type: 'string', nullable: true }, + targetPath: { type: 'string', nullable: true }, + targetId: { type: 'string', format: 'uuid', nullable: true }, + targetTitle: { type: 'string', nullable: true }, + exists: { type: 'boolean' } + } + } + } + } + } + }, + async (req, reply) => { + const page = await loadReadablePage(req, req.params.siteId, req.params.pageId) + if (!page) { + return reply.notFound('This page does not exist.') + } + // -> These come out of the page's own body, which is the half a password withholds + if (page.isLocked) { + return reply.forbidden('This page is password protected.') + } + + const links = await WIKI.models.pageLinks.outboundFor(page.id) + + return links.map((link) => ({ + href: link.href, + kind: link.kind, + targetSiteId: link.targetSiteId, + targetLocale: link.targetLocale, + targetPath: link.targetPath, + targetId: link.targetPageId, + /* + Only what the caller may read, for the reason the backlinks route filters: whether a page + exists at a path is something a rule decides they may know. A link to a page they may not + read reads as a link to nothing, which is also what clicking it would give them. + + Judged on where the RESOLVED page sits rather than on what the link said about it — an alias + or an id link names no address at all — and with its tags, since a rule may be written + against those. + */ + ...(link.targetPageId && + !mayOnPage(req, 'read:pages', { + siteId: link.targetSiteId, + locale: link.targetPageLocale ?? undefined, + path: link.targetPagePath ?? '', + tags: link.targetPageTags ?? [] + }) + ? { targetTitle: null, exists: false } + : { targetTitle: link.targetTitle, exists: Boolean(link.targetPageId) }) + })) + } + ) + /** * PAGE USER PERMISSIONS */ diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 05e4ed291..d9fdd4660 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -119,6 +119,7 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'string' } }, + allowBacklinks: { type: 'boolean' }, allowComments: { type: 'boolean' }, allowContributions: { type: 'boolean' }, allowRatings: { type: 'boolean' }, @@ -217,6 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.' }, + allowBacklinks: { type: 'boolean' }, allowComments: { type: 'boolean' }, allowContributions: { type: 'boolean' }, allowRatings: { type: 'boolean' }, diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index a93eb86c4..07b2d4bfd 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -80,6 +80,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { features: { type: 'object', properties: { + backlinks: { + type: 'boolean', + description: + 'Whether a page shows what links to it. Off hides the Links tab across the site and closes the route behind it; a page can still opt out on its own with `allowBacklinks`. Nothing about how links are recorded changes either way — the table is written by every save regardless, so turning this back on shows a list rather than an empty tab.' + }, browse: { type: 'boolean' }, diff --git a/backend/api/system.ts b/backend/api/system.ts index 4d6bca836..0b09222d8 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -1439,6 +1439,64 @@ async function routes(app: FastifyInstance) { } ) + /** + * REBUILD PAGE LINKS + * + * What every page points at, worked out again from the render each one already stores. + * + * Not a repair anybody should need after an ordinary edit: a page's links are rewritten every time + * its render is. It is for the two cases where there was nothing to derive them from at the time — + * content that predates the table, and a site whose locale prefixes or page extensions changed, + * which changes what a link already written in a page addresses without touching the page. + */ + app.post( + '/page-links/rebuild', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Rebuild the page link index', + description: + 'Queues a job that re-reads the stored render of every page on every site and records what each one links to. Runs in a worker thread because it touches all the content in the instance, so the response only says the job was queued.', + tags: ['System'], + response: { + 200: { + description: 'Rebuild queued successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + id: { + type: 'string', + format: 'uuid', + description: 'ID of the queued job, which the scheduler view lists.' + } + } + } + } + } + }, + async (req, reply) => { + const added = await WIKI.scheduler.addJob({ task: 'rebuildPageLinks' }) + if (!added?.id) { + return reply.internalServerError('The scheduler could not queue the rebuild.') + } + + await audit(req, 'admin', 'rebuildPageLinks', { jobId: added.id }) + + return { + ok: true, + message: 'Page link rebuild queued successfully.', + id: added.id + } + } + ) + /** * CHECK FOR UPDATE */ diff --git a/backend/db/migrations/20260915225306_scarlett/migration.sql b/backend/db/migrations/20260915225306_scarlett/migration.sql new file mode 100644 index 000000000..d5a092ea2 --- /dev/null +++ b/backend/db/migrations/20260915225306_scarlett/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "pageLinks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "kind" varchar(16) NOT NULL, + "href" varchar(2048) NOT NULL, + "targetSiteId" uuid NOT NULL, + "targetLocale" varchar(255), + "targetPath" varchar(255), + "targetRef" varchar(255), + "createdAt" timestamp DEFAULT now() NOT NULL, + "pageId" uuid NOT NULL, + "siteId" uuid NOT NULL +); +--> statement-breakpoint +CREATE INDEX "pageLinks_target_idx" ON "pageLinks" ("targetSiteId","targetLocale","targetPath");--> statement-breakpoint +CREATE INDEX "pageLinks_targetRef_idx" ON "pageLinks" ("targetSiteId","kind","targetRef");--> statement-breakpoint +CREATE INDEX "pageLinks_pageId_idx" ON "pageLinks" ("pageId");--> statement-breakpoint +CREATE INDEX "pageLinks_siteId_idx" ON "pageLinks" ("siteId");--> statement-breakpoint +CREATE UNIQUE INDEX "pageLinks_pageId_href_idx" ON "pageLinks" ("pageId","href");--> statement-breakpoint +CREATE UNIQUE INDEX "pages_siteId_locale_path_idx" ON "pages" ("siteId","locale","path");--> statement-breakpoint +ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_targetSiteId_sites_id_fkey" FOREIGN KEY ("targetSiteId") REFERENCES "sites"("id");--> statement-breakpoint +ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id"); \ No newline at end of file diff --git a/backend/db/migrations/20260915225306_scarlett/snapshot.json b/backend/db/migrations/20260915225306_scarlett/snapshot.json new file mode 100644 index 000000000..58fd5c8b4 --- /dev/null +++ b/backend/db/migrations/20260915225306_scarlett/snapshot.json @@ -0,0 +1,6783 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "db3e3d86-3fc2-4ab1-bab5-19ce0d1fb9aa", + "prevIds": [ + "cc57b1e9-4f25-48a3-8626-d4a09044d7cc" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "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": "comments", + "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": "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": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageLinks", + "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": "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": "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": "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": "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": 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": "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": "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": "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": true, + "dimensions": 0, + "default": "0", + "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": "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": "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": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "ratingScore", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ratingCount", + "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": "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": "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": "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": "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": "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": "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": "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": "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": "btree", + "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": "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": "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": [ + "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": [ + "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": [ + "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": [ + "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": "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": [ + "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" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "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": [ + "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": "comments_pkey", + "schema": "public", + "table": "comments", + "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": "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": "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": "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" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 28985f39f..66461256b 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -556,7 +556,15 @@ export const pages = pgTable( index('pages_isSearchableComputed_idx').on(table.isSearchableComputed), // -> One page per locale in a group, enforced here rather than in the model: a group is edited // from any of its members, so two saves racing each other are two writers of the same set - uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale) + uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale), + /* + Where a page sits, which is what a path addresses it by. Unique because two pages at one path in + one locale is the thing `createPage` and `movePage` both check for and neither can actually + prevent -- their check and their write are two statements, so two saves racing each other both + see a clear path. It is also the index the link table resolves against: "is there a page at this + address" is asked once per link on a page. + */ + uniqueIndex('pages_siteId_locale_path_idx').on(table.siteId, table.locale, table.path) ] ) @@ -634,6 +642,95 @@ export const pageHistory = pgTable( ] ) +// PAGE LINKS -------------------------- +/** + * One row per distinct link written on a page, resolved to what it addresses. + * + * Derived from the stored render the way `toc` and `searchContent` are, and rewritten wholesale + * whenever that render is — see `models/pageLinks.ts`. Three questions are asked of it: what links to + * the page being read, whether what a page links to exists, and which pages have to be revisited when + * something they point at moves. + * + * **The address is what is stored, not a resolved page id.** A link is written as a path, and after a + * move the pages pointing at the old one still say the old one — which is precisely the thing worth + * knowing, and precisely what a foreign key would erase by following the page. It also lets a link to + * a page that does not exist yet be a row like any other, which is what a red link is. Whether a + * target resolves is a join against `pages` on `(siteId, locale, path)` at read time. + * + * `/a/` and `/i/` are the exception and are stored as the reference they carry: those two + * survive a move by design, so resolving them to a path at write time would record the opposite of + * what they mean. + */ +export const pageLinks = pgTable( + 'pageLinks', + { + id: uuid().primaryKey().defaultRandom(), + /** + * What the link addresses: a page by path (`page`), by alias (`alias`) or by id (`pageId`), or an + * uploaded file (`asset`). + * + * A varchar rather than an enum, for the reason `pageHistory.action` is one — naming another kind + * of target later should not need a migration. Links leaving the wiki are not recorded at all: + * nothing asks a question about them until there is a link checker to answer it, and they would + * be the bulk of the rows on a wiki that cites its sources. + */ + kind: varchar({ length: 16 }).notNull(), + /** + * The href exactly as it appears in the page. + * + * Kept because resolving is lossy and the source is what a repair would have to edit: `../two`, + * `/en/one/two` and `/one/two.md` are one target and three strings, and only the string that was + * written can be found in the markdown again. + * + * Bounded rather than `text` because it is half of a unique btree index below, and a btree entry + * has a hard ceiling of about 2700 bytes — an href past it would fail the INSERT, and that INSERT + * is part of saving a page. 2048 is the conventional cap for a URL and far past anything a link + * in a wiki page is; `resolveLink` drops the ones that would not fit rather than truncating them + * into a different address. + */ + href: varchar({ length: 2048 }).notNull(), + /** + * Which site the target belongs to. Usually the source's own, and another one for a link written + * as an absolute URL to a second site of this instance — those are worth following rather than + * writing off as external, since a move on either site breaks them just the same. + */ + targetSiteId: uuid() + .notNull() + .references(() => sites.id), + // -> Both null for `alias` and `pageId`, which address a page without saying where it is + targetLocale: varchar({ length: 255 }), + targetPath: varchar({ length: 255 }), + /** The alias or the page id, for the two kinds that carry one. Null for the rest. */ + targetRef: varchar({ length: 255 }), + createdAt: timestamp().notNull().defaultNow(), + // -> The page the link is written on. Its rows go with it: a link is part of a page's content, + // and nothing is left to point at once the page is gone + pageId: uuid() + .notNull() + .references(() => pages.id, { onDelete: 'cascade' }), + siteId: uuid() + .notNull() + .references(() => sites.id) + }, + (table) => [ + // -> "What links here", and "what points at this path" for a page about to be moved. Leading with + // the site because every one of those questions is asked within one + index('pageLinks_target_idx').on(table.targetSiteId, table.targetLocale, table.targetPath), + // -> The same question for the two kinds that address a page without a path + index('pageLinks_targetRef_idx').on(table.targetSiteId, table.kind, table.targetRef), + // -> Every link on one page, which is both the read for "do these targets exist" and the delete + // half of rewriting a page's links + index('pageLinks_pageId_idx').on(table.pageId), + index('pageLinks_siteId_idx').on(table.siteId), + /* + One row per spelling, not per target: a page linking to the same place as `../two` and as + `/one/two` has two links to repair and two rows saying so. Two identical hrefs on one page are + one row, since there is nothing to tell them apart and nothing that would ask. + */ + uniqueIndex('pageLinks_pageId_href_idx').on(table.pageId, table.href) + ] +) + // PAGE EDIT SUBMISSIONS --------------- /** * An edit suggested by somebody who may read a page but not change it, waiting to be reviewed. diff --git a/backend/helpers/pageLinks.ts b/backend/helpers/pageLinks.ts new file mode 100644 index 000000000..08d9890d6 --- /dev/null +++ b/backend/helpers/pageLinks.ts @@ -0,0 +1,272 @@ +import * as cheerio from 'cheerio' +import { isPageUrl, normalizePagePath, splitLocalePath, stripPageExtension } from './common.ts' + +/** + * Reading a link the way the reader's browser will. + * + * A page stores the HTML its editor produced, and that HTML carries the href the author wrote: + * `fileSrc` rewrites image sources and deliberately leaves links alone, because a relative link means + * exactly what it says. So `../two`, `/en/one/two`, `/one/two.md` and + * `https://wiki.example.com/en/one/two` can all be the same page, and something has to say so before + * any question about links can be answered. + * + * That makes this the THIRD copy of the rules for reading a page URL, after `helpers/common.ts` — + * which it composes rather than reimplements — and `frontend/src/helpers/pagePaths.js`. It has to + * agree with both: a link this reads differently from the router is a backlink pointing somewhere the + * reader does not land. + */ + +/** + * Every href in a render, exactly as written and with repeats left in. + * + * A page's links are read from its stored HTML rather than from its source, because there is no + * markdown parser on this side at all: the render is produced in the editor's browser and sent up, + * and it is the one settled form of a page the server holds. It is also what makes rebuilding a + * wiki's links cost a parse per page instead of a headless browser per page. + * + * @param $ A document already loaded, for the caller that has one — `postProcess` is holding the very + * tree this would otherwise re-parse. + */ +export function hrefsFrom($: cheerio.CheerioAPI): string[] { + const hrefs: string[] = [] + for (const el of $('a[href]')) { + const href = $(el).attr('href') + if (href) { + hrefs.push(href) + } + } + return hrefs +} + +/** The same, for a stored render with nothing loaded — which is every page but the one being saved. */ +export function linksFromRender(html?: string | null): string[] { + if (!html) { + return [] + } + return hrefsFrom(cheerio.load(html, null, false)) +} + +/** What a link addresses. See the `kind` column. */ +export type PageLinkKind = 'page' | 'alias' | 'pageId' | 'asset' + +export interface ResolvedLink { + kind: PageLinkKind + /** The href as written, which is what a repair has to find in the source again. */ + href: string + /** Which site the target is on — the source's own, unless the href named another one. */ + targetSiteId: string + /** Where the target sits, for the two kinds that say. Null for `alias` and `pageId`. */ + targetLocale: string | null + targetPath: string | null + /** The alias or the page id, for the two kinds that carry one. */ + targetRef: string | null +} + +/** The page a link is written on, which is what a relative href resolves against. */ +export interface LinkSource { + siteId: string + locale: string + path: string +} + +/** + * The origin every href is resolved against. + * + * Nothing here cares what the host is, only whether two of them are the same, and a render is + * processed outside any request — so there is no real origin to reach for. A placeholder that cannot + * collide with a hostname anybody has configured is what makes `new URL()` usable on a relative href, + * and comparing what comes out against this is how "did this link stay on this site" is asked. + */ +const LOCAL_ORIGIN = 'https://page-links.invalid' + +/** Where uploaded files are served from, and the one non-page path worth recording. */ +const FILES_PREFIX = '/_files/' + +/** The short links to a page that survive a move, and the segment each is addressed by. */ +const ALIAS_PREFIX = '/a/' +const PAGE_ID_PREFIX = '/i/' + +/** + * The longest href that gets a row, matching the column it is stored in. + * + * Not a judgement about what a link may be — it is what a unique btree index can hold, and the insert + * is part of saving a page. Past it the link is not recorded, which costs a row in a derived table + * and never the save. + */ +const MAX_HREF_LENGTH = 2048 + +/** + * Which site an href ended up on, or null when it left the instance. + * + * A link written as an absolute URL to another site of this wiki is followed rather than written off: + * `WIKI.sitesMappings` is the same lookup the request hooks do, so a second site's hostname resolves + * to that site and a move there breaks the link just as visibly. + * + * **The catch-all mapping is deliberately not consulted.** `*` answers for every hostname that + * reaches this server, so honouring it here would make every external link a page link — every URL a + * page cites would resolve to a path on the site that happens to be bound to `*`. The cost is the + * other half of that: on a site with no hostname of its own, a link somebody wrote by pasting the + * address out of their browser is not recognised as internal, because there is nothing configured to + * recognise it against. Links written as paths — which is every link the link picker produces — never + * reach this at all, and are unaffected. + */ +function siteForHost(hostname: string, sourceSiteId: string): string | null { + if (hostname === new URL(LOCAL_ORIGIN).hostname) { + return sourceSiteId + } + return WIKI.sitesMappings?.[hostname] ?? null +} + +/** + * Read one href as the address of something in this wiki, or null when it is not one. + * + * Null covers rather a lot, and all of it on purpose: an empty href, a bare fragment, a `mailto:` or + * `tel:`, a link to another server, and every path the wiki serves for itself — `/_admin`, `/login`, + * `/_api` — none of which is content this can be asked a question about. Links leaving the wiki are + * not recorded at all; see the `kind` column. + * + * @param source The page the link is written on. A relative href resolves against the URL that page + * is served at, locale prefix and all, which is what the browser following it does. + */ +export function resolveLink(href: string, source: LinkSource): ResolvedLink | null { + const raw = (href ?? '').trim() + if (raw.length < 1 || raw.length > MAX_HREF_LENGTH || raw.startsWith('#')) { + return null + } + + /* + A scheme that is not http(s) is not a page under any reading -- `mailto:`, `tel:`, `data:`, and + the `javascript:` a sanitised render should not be carrying anyway. Tested before `new URL()` + rather than after, since those parse perfectly well and would otherwise have to be excluded by + protocol afterwards. + */ + if (/^[a-z][a-z\d+.-]*:/i.test(raw) && !/^https?:/i.test(raw)) { + return null + } + + let url: URL + try { + // -> The page's own address as the base, so `../two` from `one/deep/three` lands where a reader + // clicking it lands. `urlFor` is what puts the site's locale prefix on it, if it uses one + url = new URL( + raw, + `${LOCAL_ORIGIN}${WIKI.models.pages.urlFor(source.siteId, source.locale, source.path)}` + ) + } catch { + return null + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + + const targetSiteId = siteForHost(url.hostname, source.siteId) + if (!targetSiteId) { + return null + } + + // -> The query and the fragment address a part of the target, never a different one, so what is + // left is the whole of where the link goes + const urlPath = url.pathname + + if (urlPath.startsWith(ALIAS_PREFIX)) { + const alias = decodeURIComponent(urlPath.slice(ALIAS_PREFIX.length)).replace(/\/+$/, '') + return alias.length > 0 + ? { + kind: 'alias', + href: raw, + targetSiteId, + targetLocale: null, + targetPath: null, + targetRef: alias + } + : null + } + if (urlPath.startsWith(PAGE_ID_PREFIX)) { + const id = decodeURIComponent(urlPath.slice(PAGE_ID_PREFIX.length)).replace(/\/+$/, '') + return id.length > 0 + ? { + kind: 'pageId', + href: raw, + targetSiteId, + targetLocale: null, + targetPath: null, + targetRef: id + } + : null + } + + if (urlPath.startsWith(FILES_PREFIX)) { + return resolveFile(raw, urlPath, targetSiteId) + } + + if (!isPageUrl(urlPath)) { + return null + } + return resolvePage(raw, urlPath, targetSiteId) +} + +/** + * A page address, as the SEO hook and the router between them read one. + * + * Same order as `index.ts`: the extension comes off first, since `/en/one/two.md` carries both, and + * the locale prefix second. A path arriving without a prefix is the site's primary locale, which is + * where the server redirects it and what the app loads for it. + */ +function resolvePage(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null { + const site = WIKI.sites?.[targetSiteId] + const trimmed = urlPath.length > 1 && urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath + + const withoutExtension = stripPageExtension(trimmed, site?.config?.pageExtensions) ?? trimmed + + const locales = site?.config?.locales + const split = splitLocalePath( + withoutExtension, + WIKI.models.locales.urlPrefixesFor(locales?.active) + ) + + return { + kind: 'page', + href, + targetSiteId, + // -> A path with no prefix names the primary locale, which is where the SEO hook redirects it + // and what the app loads for it. Same fallback as `pages.defaultLocale` + targetLocale: split?.locale ?? locales?.primary ?? 'en', + // -> The site root is the page stored at the empty path, which is what `normalizePagePath` makes + // of `/` -- and of `/fr` once its prefix has come off above + targetPath: normalizePagePath(split?.path ?? withoutExtension), + targetRef: null + } +} + +/** + * An uploaded file, addressed the way `controllers/files.ts` serves one. + * + * `/_files//`, and no locale segment: a file URL names a path and nothing else, + * and `getAssetByPath` picks the site's primary locale among the translations filed under it. So + * unlike a page link there is no locale to record — the path IS the address. + * + * Lowercased whole, which is how the lookup reads it: the tree stores a file name in lower case and + * `resolveAssetPath` keys its cache on the path in lower case, so two spellings of one file must not + * become two targets here. + * + * Recorded for the same reason a page link is: a file moves when the folder holding it is renamed, + * and what pointed at it is the thing worth knowing. + */ +function resolveFile(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null { + const rest = decodeURIComponent(urlPath.slice(FILES_PREFIX.length)) + .split('/') + .filter(Boolean) + .join('/') + .toLowerCase() + if (rest.length < 1) { + return null + } + return { + kind: 'asset', + href, + targetSiteId, + targetLocale: null, + targetPath: rest, + targetRef: null + } +} diff --git a/backend/locales/en.json b/backend/locales/en.json index 8c76c20c9..c2fb0e45f 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -180,6 +180,7 @@ "admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders", "admin.audit.actions.purgePageHistory": "Purged page history", "admin.audit.actions.purgeSampleContent": "Purged the sample content", + "admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index", "admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index", "admin.audit.actions.refreshIconSets": "Refreshed the icon sets", "admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates", @@ -464,6 +465,8 @@ "admin.flags.title": "Flags", "admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!", "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!", + "admin.general.allowBacklinks": "Allow Backlinks", + "admin.general.allowBacklinksHint": "Give every page a Links tab listing the pages that point at it. A page can still hide its own from its properties, and links keep being recorded either way.", "admin.general.allowBrowse": "Allow Browsing", "admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?", "admin.general.allowCollaborativeEditing": "Allow Collaborative Editing", @@ -1445,6 +1448,12 @@ "admin.utilities.purgeSampleFailed": "The sample content could not be deleted.", "admin.utilities.purgeSampleHint": "Delete every page on this site tagged \"test\", which is what Generate Sample Content tags the pages it writes.", "admin.utilities.purgeSampleSuccess": "There was no sample content to delete. | Deleted 1 page. | Deleted {count} pages.", + "admin.utilities.rebuildPageLinks": "Rebuild Page Links", + "admin.utilities.rebuildPageLinksConfirm": "Every page on every site will be read again to work out what it links to.", + "admin.utilities.rebuildPageLinksConfirmWarn": "Nothing about your content is changed — only the record of which page points at which. This normally keeps itself up to date, so it is worth running after importing content written elsewhere, or after changing a site's locale prefixes or page extensions.", + "admin.utilities.rebuildPageLinksFailed": "Failed to queue a page link rebuild.", + "admin.utilities.rebuildPageLinksHint": "Work out again what every page links to, from the content already stored. Runs in the background.", + "admin.utilities.rebuildPageLinksSuccess": "A page link rebuild has been initiated and will start shortly.", "admin.utilities.scanPageProblems": "Scan for Page Problems", "admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.", "admin.utilities.subtitle": "Maintenance and miscellaneous tools", @@ -1793,6 +1802,12 @@ "common.license.ccbynd": "Creative Commons Attribution-NoDerivs License", "common.license.ccbysa": "Creative Commons Attribution-ShareAlike License", "common.license.none": "None", + "common.links.count": "1 page links here | {count} pages link here", + "common.links.loadFailed": "Could not load the pages linking here.", + "common.links.loading": "Loading links…", + "common.links.none": "No page links here yet.", + "common.links.noneHint": "Pages that link to this one will be listed here.", + "common.links.title": "Pages linking here", "common.loading": "Loading...", "common.modernBrowser": "modern browser", "common.newpage.create": "Create Page", @@ -1833,6 +1848,7 @@ "common.page.suggestSubmitted": "Your suggested edits have been submitted.", "common.page.suggestSubmittedHint": "They are pending review. You will be able to keep editing them until a reviewer accepts or declines them.", "common.page.suggestSubmittedHintGuest": "They are pending review by this site's editors.", + "common.page.tabLinks": "Links", "common.page.tags": "Tags", "common.page.tagsMatching": "Pages matching tags", "common.page.toc": "Table of Contents", @@ -2108,6 +2124,7 @@ "editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.", "editor.pendingAssetsUploading": "Uploading assets...", "editor.props.alias": "Alias", + "editor.props.allowBacklinks": "Show Links Tab", "editor.props.allowComments": "Allow Comments", "editor.props.allowCommentsHint": "Enable commenting abilities on this page.", "editor.props.allowContributions": "Allow Contributions", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 16b1f961d..ae2728493 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -117,6 +117,7 @@ export const AUDIT_ACTIONS = { 'updateSecurity', 'updateSearchConfig', 'rebuildSearchIndex', + 'rebuildPageLinks', 'installExtension', 'updateApiState', 'updateMetricsState', diff --git a/backend/models/index.ts b/backend/models/index.ts index 78f8e9613..06872d04f 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -17,6 +17,7 @@ import { mail } from './mail.ts' import { metrics } from './metrics.ts' import { navigation } from './navigation.ts' import { pageHistory } from './pageHistory.ts' +import { pageLinks } from './pageLinks.ts' import { pages } from './pages.ts' import { pageWatching } from './pageWatching.ts' import { passkeys } from './passkeys.ts' @@ -52,6 +53,7 @@ export default { metrics, navigation, pageHistory, + pageLinks, pages, pageWatching, passkeys, diff --git a/backend/models/pageLinks.ts b/backend/models/pageLinks.ts new file mode 100644 index 000000000..bbdf1a9ba --- /dev/null +++ b/backend/models/pageLinks.ts @@ -0,0 +1,416 @@ +import { and, eq, ne, or, sql } from 'drizzle-orm' +import { pageLinks as pageLinksTable, pages as pagesTable } from '../db/schema.ts' +import { linksFromRender, resolveLink } from '../helpers/pageLinks.ts' +import type { LinkSource, PageLinkKind, ResolvedLink } from '../helpers/pageLinks.ts' + +/** + * Page links model + * + * What every page points at, kept in step with the page. + * + * The rows are derived, exactly as `toc` and `searchContent` are, and from the same thing: the stored + * render. There is no markdown parser on this side — a page's HTML is produced in the editor's + * browser and sent up — so the render is the only settled form of a page the server has, and + * `rendering.linksFromRender` is what reads the anchors out of it. Two link sources are not in the + * render at all and are collected by the pages model instead: a redirection's target lives in its + * content, and a page's relations are stored as their own column. + * + * Nothing here decides what a link MEANS — `helpers/pageLinks.ts` does that, and is where the rules + * for reading a page URL live. This model is the storage half: swap a page's rows, and answer the + * three questions the table exists for. + */ + +/** + * The editor whose content IS a link. + * + * Spelled out here rather than imported from `models/pages.ts`, which pulls in the storage, search and + * tree models behind it — this model is one of the few a worker thread loads, and the rebuild utility + * runs in one. See `worker.ts` on what an import costs there. + */ +const REDIRECT_EDITOR = 'redirect' + +/** The columns of a page that say what it links to. */ +export interface PageLinkSource { + id: string + siteId: string + locale: string + path: string + editor: string + content: string | null + relations: unknown + render: string | null +} + +/** A link on a page, with the page it was found on. */ +export interface PageLinkRow { + id: string + pageId: string + kind: PageLinkKind + href: string + targetSiteId: string + targetLocale: string | null + targetPath: string | null + targetRef: string | null +} + +/** One page linking to another, as a "what links here" list shows it. */ +export interface Backlink { + pageId: string + siteId: string + locale: string + path: string + title: string + description: string | null + /** The page's own icon, as an Iconify reference. Empty where it has none. */ + icon: string | null + /** How the link is written, since the same page may point at a target more than one way. */ + href: string + kind: PageLinkKind + tags: string[] + publishState: string + /** Where that page is, as a path on ITS OWN site — locale prefix included only where that site + brackets its URLs by one. */ + url: string + /** + * The host that site answers on, so a reader looking at one site can still follow a link back to a + * page on another. Null for the catch-all site, which has no host of its own to name. + */ + hostname: string | null +} + +/** A link on a page, and whether anything is actually at the other end. */ +export interface OutboundLink extends PageLinkRow { + /** The page it resolves to, or null when nothing is there — a red link. */ + targetPageId: string | null + targetTitle: string | null + /* + Where the resolved page actually sits, and what it is tagged with, rather than what the link said + about it. Both are here so the caller can ask whether this reader may know the page exists: an + alias or an id link carries no address at all, and a page rule can be written against tags. + */ + targetPageLocale: string | null + targetPagePath: string | null + targetPageTags: string[] | null +} + +/** Where a page sits, which is how a link addresses it. */ +export interface LinkTarget { + siteId: string + locale: string + path: string +} + +class PageLinks { + /** + * Whether this site shows what links to a page. + * + * **Display only.** Off, the Links tab is not drawn and the route behind it answers 404 — and every + * save still records what it links to, exactly as before. That is the whole point of putting the + * switch here rather than around `refreshForPage`: a site that turns this off for a year and back + * on again has a complete answer waiting, where one that stopped writing the table would have a + * year of pages to rebuild before the tab said anything true. + * + * `!== false` rather than a truth test, so a site whose config blob has never been written with the + * key reads as on — which is what the default in `sites.createSite` says it is. + */ + isAllowed(siteId: string | undefined): boolean { + return siteId ? WIKI.sites[siteId]?.config?.features?.backlinks !== false : false + } + + /** + * Work out everything a page points at, and store it. + * + * Three sources, because a link is not only a thing in an article: + * + * - **The render**, which is where an author's own links are, and where nearly all of them are. + * - **A redirection's target**, which is not in a render at all — a redirect page has no body, it + * has a destination, and it is the strongest link in the wiki: a reader following one never sees + * that it broke, they just land nowhere. + * - **The page's relations**, the sidebar links its properties dialog collects. Stored as their own + * column, written by the same link picker that writes one into content, and just as breakable. + * + * @param renderHrefs What `postProcess` already read out of the render it just produced, for a save + * that is storing one. Absent, the stored render is parsed instead — which is the + * case for a page that MOVED: nothing about it changed, but every relative link + * on it now resolves somewhere else. + */ + async refreshForPage(page: PageLinkSource, renderHrefs?: string[]): Promise { + const hrefs = renderHrefs ?? linksFromRender(page.render) + + if (page.editor === REDIRECT_EDITOR && page.content) { + try { + const redirect = JSON.parse(page.content) + // -> `kind: 'url'` is a destination off this wiki, which `resolveLink` would discard anyway. + // Passed through regardless, so that the one place deciding what is a link stays the one + // place deciding it + if (typeof redirect?.target === 'string') { + hrefs.push(redirect.target) + } + } catch { + // -> A redirection whose content will not parse points nowhere, which is no links rather than + // a failed save. `normalizeRedirectContent` is what stops one being written in the first + // place + } + } + + for (const relation of (Array.isArray(page.relations) ? page.relations : []) as any[]) { + if (typeof relation?.target === 'string') { + hrefs.push(relation.target) + } + } + + await this.replaceFor( + { pageId: page.id, siteId: page.siteId, locale: page.locale, path: page.path }, + hrefs + ) + } + + /** + * The same, for a caller holding an id rather than a row. + * + * What everything but a create reaches for: a save, a move, a folder rename and the rebuild utility + * all need the page as it stands AFTER their write, so reading it back is the point rather than an + * overhead. + */ + async refreshById(siteId: string, pageId: string, renderHrefs?: string[]): Promise { + const rows = await WIKI.db + .select({ + id: pagesTable.id, + siteId: pagesTable.siteId, + locale: pagesTable.locale, + path: pagesTable.path, + editor: pagesTable.editor, + content: pagesTable.content, + relations: pagesTable.relations, + render: pagesTable.render + }) + .from(pagesTable) + .where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId))) + .limit(1) + + // -> Gone while the save that asked for this was in flight. Its rows went with it + if (rows[0]) { + await this.refreshForPage(rows[0] as PageLinkSource, renderHrefs) + } + } + + /** + * Replace every link recorded for a page. + * + * Wholesale rather than differential: a save rewrites the page, and working out which links + * survived it costs more than the handful of rows it would save. In one transaction so that a page + * is never momentarily linkless — the backlinks of everything it points at would flicker. + * + * Resolution happens here rather than at extraction time because it needs the page's own address: + * `../two` means a different page depending on where the page holding it sits, which is also why + * this has to run again when a page MOVES even though nothing about its content changed. + * + * Unresolvable hrefs are dropped silently — an external link, a `mailto:`, a bare fragment, an + * admin path. See `resolveLink` for the whole list; none of them is a link this can be asked a + * question about. + * + * @param source The page the links were found on + * @param hrefs Every href the page carries, in any order and with repeats + */ + async replaceFor(source: LinkSource & { pageId: string }, hrefs: string[]): Promise { + const resolved = new Map() + for (const href of hrefs) { + const link = resolveLink(href, source) + if (link) { + // -> One row per spelling, so the same href written twice on a page is one link. Keyed on the + // href as written, which is what the unique index holds + resolved.set(link.href, link) + } + } + + await WIKI.db.transaction(async (trx: any) => { + await trx.delete(pageLinksTable).where(eq(pageLinksTable.pageId, source.pageId)) + if (resolved.size < 1) { + return + } + await trx.insert(pageLinksTable).values( + [...resolved.values()].map((link) => ({ + pageId: source.pageId, + siteId: source.siteId, + kind: link.kind, + href: link.href, + targetSiteId: link.targetSiteId, + targetLocale: link.targetLocale, + targetPath: link.targetPath, + targetRef: link.targetRef + })) + ) + }) + } + + /** Forget everything a page pointed at, for a page that is going. */ + async removeFor(pageId: string): Promise { + await WIKI.db.delete(pageLinksTable).where(eq(pageLinksTable.pageId, pageId)) + } + + /** + * Every page linking to a target, as "what links here" asks it. + * + * Addressed by locale and path rather than by page id, and deliberately: a link says where it + * points, so the pages pointing at a page that has moved are still pointing at where it WAS. Asking + * by address is what lets a move ask about the path it is leaving, which is the only time that + * question has an answer. + * + * The two id-addressed kinds are folded in by page id, since those survive a move by design and + * would otherwise be missing from the one list anybody reads. + * + * Nothing here filters by what the caller may read: a backlink names a page, and which of them this + * reader is allowed to know about is a page rule the route resolves — see `api/pages.ts`. + */ + async backlinksFor( + target: LinkTarget, + { pageId, alias }: { pageId?: string; alias?: string | null } = {} + ): Promise { + const addressedByPath = and( + eq(pageLinksTable.kind, 'page'), + eq(pageLinksTable.targetLocale, target.locale), + eq(pageLinksTable.targetPath, target.path) + ) + const addressedById = pageId + ? and(eq(pageLinksTable.kind, 'pageId'), eq(pageLinksTable.targetRef, pageId)) + : undefined + const addressedByAlias = alias + ? and(eq(pageLinksTable.kind, 'alias'), eq(pageLinksTable.targetRef, alias)) + : undefined + + const rows = await WIKI.db + .select({ + pageId: pageLinksTable.pageId, + href: pageLinksTable.href, + kind: pageLinksTable.kind, + siteId: pagesTable.siteId, + locale: pagesTable.locale, + path: pagesTable.path, + title: pagesTable.title, + description: pagesTable.description, + icon: pagesTable.icon, + tags: pagesTable.tags, + publishState: pagesTable.publishState + }) + .from(pageLinksTable) + .innerJoin(pagesTable, eq(pagesTable.id, pageLinksTable.pageId)) + .where( + and( + eq(pageLinksTable.targetSiteId, target.siteId), + // -> A page linking to itself is not a backlink, it is a page mentioning where it already is + ...(pageId ? [ne(pageLinksTable.pageId, pageId)] : []), + or(addressedByPath, addressedById, addressedByAlias) + ) + ) + .orderBy(pagesTable.locale, pagesTable.path) + + return rows.map((row: any) => ({ + ...row, + tags: row.tags ?? [], + // -> A backlink may be written on another site of this instance, and a bare path would resolve + // against the host the reader is already on. Same pair as `getRecentlyEdited`, for the same + // reason: `*` is the catch-all rather than a host, and a link to it is wherever you are + url: WIKI.models.pages.urlFor(row.siteId, row.locale, row.path), + hostname: + WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*' + ? WIKI.sites[row.siteId].hostname + : null + })) as Backlink[] + } + + /** + * Every link written on a page, with whether the far end exists. + * + * The join is what answers the red-link question, and it is a join rather than a stored flag for + * the reason the whole table is addressed by path: a page created at a path somebody had already + * linked to makes that link good, without anything having to notice. + * + * Assets are left unresolved here — they are recorded so that a moved file can be traced back to + * what pointed at it, and answering whether one exists means a tree lookup per link. + */ + async outboundFor(pageId: string): Promise { + const rows = await WIKI.db + .select({ + id: pageLinksTable.id, + pageId: pageLinksTable.pageId, + kind: pageLinksTable.kind, + href: pageLinksTable.href, + targetSiteId: pageLinksTable.targetSiteId, + targetLocale: pageLinksTable.targetLocale, + targetPath: pageLinksTable.targetPath, + targetRef: pageLinksTable.targetRef, + targetPageId: pagesTable.id, + targetTitle: pagesTable.title, + targetPageLocale: pagesTable.locale, + targetPagePath: pagesTable.path, + targetPageTags: pagesTable.tags + }) + .from(pageLinksTable) + .leftJoin( + pagesTable, + or( + and( + eq(pageLinksTable.kind, 'page'), + eq(pagesTable.siteId, pageLinksTable.targetSiteId), + eq(pagesTable.locale, pageLinksTable.targetLocale), + eq(pagesTable.path, pageLinksTable.targetPath) + ), + and( + eq(pageLinksTable.kind, 'pageId'), + sql`${pagesTable.id}::text = ${pageLinksTable.targetRef}` + ), + and( + eq(pageLinksTable.kind, 'alias'), + eq(pagesTable.siteId, pageLinksTable.targetSiteId), + eq(pagesTable.alias, pageLinksTable.targetRef) + ) + ) + ) + .where(eq(pageLinksTable.pageId, pageId)) + .orderBy(pageLinksTable.href) + + return rows as OutboundLink[] + } + + /** + * The pages that will be left pointing at nothing if these targets move or go. + * + * The bulk form of `backlinksFor`, for a folder rename — which moves every page under it at once — + * and for anything else that has to warn about a change before making it. One query for the lot, + * since a rename can move hundreds of pages and asking per page would be hundreds of round trips. + * + * Only path-addressed links: the id and alias kinds survive a move, so they are not affected by + * one and do not belong in a list of what it breaks. + * + * @returns The id of every page holding such a link, without repeats + */ + async dependentsOf( + siteId: string, + targets: { locale: string; path: string }[] + ): Promise { + if (targets.length < 1) { + return [] + } + const rows = await WIKI.db + .selectDistinct({ pageId: pageLinksTable.pageId }) + .from(pageLinksTable) + .where( + and( + eq(pageLinksTable.targetSiteId, siteId), + eq(pageLinksTable.kind, 'page'), + or( + ...targets.map((target) => + and( + eq(pageLinksTable.targetLocale, target.locale), + eq(pageLinksTable.targetPath, target.path) + ) + ) + ) + ) + ) + + return rows.map((row: any) => row.pageId as string) + } +} + +export const pageLinks = new PageLinks() diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 5d17d2f98..f4bf94737 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -134,6 +134,7 @@ const reAlias = /^[a-zA-Z0-9-_]*$/ /** Fields kept in the `config` blob rather than as columns, and flattened again on the way out. */ const CONFIG_FIELDS = [ + 'allowBacklinks', 'allowComments', 'allowContributions', 'allowRatings', @@ -207,6 +208,8 @@ export interface Page { password?: string | null /** Whether the body was withheld because the page is password protected. See `getPage`. */ isLocked: boolean + /** Whether this page shows what links to it. The site-wide switch is `features.backlinks`. */ + allowBacklinks: boolean relations: any[] /** * This page in the other locales, as far as the requester may see them. Empty for a page that is @@ -257,6 +260,7 @@ export interface PageInput { isBrowsable?: boolean isSearchable?: boolean password?: string + allowBacklinks?: boolean relations?: any[] /** * The page's counterparts in other locales, stating the whole set rather than adding to it: a locale @@ -525,6 +529,12 @@ class Pages { ...((withContent || row.editor === REDIRECT_EDITOR) && !locked ? { content: row.content ?? '' } : {}), + /* + Absent means yes, as every one of these does: the key is only written once somebody has + edited the page's properties, and a page nobody has touched shows what links to it. The + site-wide switch is `features.backlinks` -- see `pageLinks.isAllowed`. + */ + allowBacklinks: config.allowBacklinks ?? true, allowComments: config.allowComments ?? true, allowContributions: config.allowContributions ?? true, allowRatings: config.allowRatings ?? true, @@ -1436,7 +1446,7 @@ class Pages { }) const alias = await this.validateAlias(siteId, input.alias) - const { render, toc, text } = await WIKI.models.rendering.postProcess( + const { render, toc, text, links } = await WIKI.models.rendering.postProcess( siteId, input.render ?? '', { @@ -1518,6 +1528,10 @@ class Pages { throw err } + // -> What this page points at, which is only knowable once it has an id and an address of its + // own: a relative link resolves against the page holding it + await WIKI.models.pageLinks.refreshForPage(page, links) + const versionId = await WIKI.models.pageHistory.record({ siteId, pageId: page.id, @@ -1646,14 +1660,20 @@ class Pages { } // -> A render only means anything next to the content it came from, so the two move together + let renderHrefs: string[] | undefined if (patch.render !== undefined) { - const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, patch.render, { - scripts: hasPermission(actor, 'write:scripts'), - styles: hasPermission(actor, 'write:styles') - }) + const { render, toc, text, links } = await WIKI.models.rendering.postProcess( + siteId, + patch.render, + { + scripts: hasPermission(actor, 'write:scripts'), + styles: hasPermission(actor, 'write:styles') + } + ) values.render = render values.toc = toc values.searchContent = text + renderHrefs = links } if (CONFIG_FIELDS.some((field) => patch[field] !== undefined)) { @@ -1678,6 +1698,14 @@ class Pages { const updated = (await this.getPage({ siteId, id })) as Page + /* + Unconditionally, and not only for a save that carried a render: a redirection's destination is + its content, and the sidebar relations are a column of their own, so a save touching either of + those changes what the page links to without the render moving at all. Read back rather than + assembled from the patch, for the same reason `changedFields` is worked out against the row. + */ + await WIKI.models.pageLinks.refreshById(siteId, id, renderHrefs) + const versionId = await WIKI.models.pageHistory.record({ siteId, pageId: id, @@ -1906,6 +1934,14 @@ class Pages { */ await WIKI.models.search.indexPage(id, newLocale) + /* + Not the pages pointing AT this one -- those still say where it was, which is the thing worth + knowing and exactly what the table is for. This is the other direction: every relative link the + moved page itself carries resolved against the folder it used to sit in, and now resolves + against a different one. Nothing about its content changed and every one of its links may have. + */ + await WIKI.models.pageLinks.refreshById(siteId, id) + // -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a // versioned target's history of the file attached to it, and the rewrite is because a move may // carry a new title and always carries a new modification time, both of which are in the copy @@ -2432,7 +2468,11 @@ class Pages { html: string, permissions: RenderPermissions ): Promise { - const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions) + const { render, toc, text, links } = await WIKI.models.rendering.postProcess( + siteId, + html, + permissions + ) const updated = await WIKI.db .update(pagesTable) @@ -2443,6 +2483,9 @@ class Pages { // -> Nothing was updated when the page went while it sat in the queue if (updated[0]) { await WIKI.models.search.indexPage(id, updated[0].locale) + // -> An imported page is saved with no render at all and gets one from here, so this is where + // its links first appear + await WIKI.models.pageLinks.refreshById(siteId, id, links) // -> This is the column the injected copy of a page IS, so a re-render changes it invalidateAppShellCache() } @@ -2547,6 +2590,7 @@ class Pages { ): Record { const defaults = WIKI.sites[siteId]?.config?.defaults ?? {} return { + allowBacklinks: input.allowBacklinks ?? existing.allowBacklinks ?? true, allowComments: input.allowComments ?? existing.allowComments ?? true, allowContributions: input.allowContributions ?? existing.allowContributions ?? true, allowRatings: input.allowRatings ?? existing.allowRatings ?? true, diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts index e5259f3cd..47dda1e3c 100644 --- a/backend/models/rendering.ts +++ b/backend/models/rendering.ts @@ -4,6 +4,7 @@ import { eq, inArray, sql } from 'drizzle-orm' import { flipFromString, rotateFromString } from '@iconify/utils' import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts' import { CustomError } from '../helpers/common.ts' +import { hrefsFrom } from '../helpers/pageLinks.ts' import type { IconifyIcon } from '@iconify/types' import type { IconifyIconCustomisations } from '@iconify/utils' @@ -64,6 +65,15 @@ export interface PostProcessResult { toc: TocNode[] /** Plain text, for the search index. */ text: string + /** + * Every href the page carries, exactly as written and with repeats left in. + * + * Raw on purpose: what an href ADDRESSES depends on where the page holding it sits, on the site's + * locale prefixes and on its page extensions, none of which is rendering's business. See + * `helpers/pageLinks.ts`, which reads them, and `models/pageLinks.ts`, which stores what it makes + * of them. + */ + links: string[] } /** @@ -376,7 +386,10 @@ class Rendering { return { render: $.html(), toc, - text: this.extractText($) + text: this.extractText($), + // -> Read off the tree that is already loaded rather than by re-parsing the output, which is + // what `linksFromRender` has to do for a page nobody is saving + links: hrefsFrom($) } } diff --git a/backend/models/sites.ts b/backend/models/sites.ts index c0e6bb0d5..f80257971 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -126,6 +126,11 @@ class Sites { } }, features: { + // -> On, because what a wiki is for is pages that point at each other, and what points at + // the page in front of you is worth knowing by default. It gates the TAB and the route + // behind it; links are recorded either way, so turning it off and on again shows the + // same list rather than an empty one. + backlinks: true, browse: true, collaborativeEditing: true, ratings: false, @@ -421,6 +426,7 @@ class Sites { } }, features: { + backlinks: true, browse: true, collaborativeEditing: true, ratings: false, diff --git a/backend/models/tree.ts b/backend/models/tree.ts index 4a9259191..891b597da 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -1001,6 +1001,13 @@ class Tree { // -> A folder rename never crosses locales, so the page's own is where it came from too { locale: page.locale, path: page.previousPath } ) + /* + And the links each page WRITES, which is the other thing a path carries: a relative link + resolved against the folder these pages used to sit in, and resolves against the renamed one + now. The links pointing at them are deliberately left alone -- they say where the pages were, + which is what makes them findable as the ones this rename has broken. + */ + await WIKI.models.pageLinks.refreshById(folder.siteId, page.id) } // -> A storage target that lays its content out by path has every one of those files to move. @@ -1205,6 +1212,7 @@ class Tree { password: page.password ?? '', relations: page.relations, tags: page.tags, + allowBacklinks: page.allowBacklinks, allowComments: page.allowComments, allowContributions: page.allowContributions, allowRatings: page.allowRatings, @@ -1470,6 +1478,9 @@ class Tree { }, { locale: folder.locale, path: page.previousPath } ) + // -> As in `renameFolder`: every relative link these pages carry resolved against where they + // were. This move can cross locales as well, which changes what a link's prefix means too + await WIKI.models.pageLinks.refreshById(siteId, page.id) } // -> A storage target that lays its content out by path has every one of those files to move. diff --git a/backend/tasks/workers/rebuild-page-links.ts b/backend/tasks/workers/rebuild-page-links.ts new file mode 100644 index 000000000..9d0c3404a --- /dev/null +++ b/backend/tasks/workers/rebuild-page-links.ts @@ -0,0 +1,106 @@ +import NodeCache from 'node-cache' +import { asc, gt } from 'drizzle-orm' +import { pages as pagesTable } from '../../db/schema.ts' +import { locales } from '../../models/locales.ts' +import { pageLinks } from '../../models/pageLinks.ts' +import { pages } from '../../models/pages.ts' +import { settings } from '../../models/settings.ts' +import { sites } from '../../models/sites.ts' + +/** + * Work out what every page on the wiki links to, from scratch. + * + * The links of a page are derived from its stored render and rewritten whenever that render is, so + * ordinary editing keeps them current on its own. This is for the cases where there was nothing to + * derive them from at the time: pages that predate the table, and a wiki whose locale prefixes or + * page extensions changed, which silently changes what a link that was already written ADDRESSES. + * + * Offered under Admin → Utilities and never run on its own. It is not a migration and not a boot step: + * a wiki with no links recorded works, it simply has no backlinks to show yet, and a rebuild that ran + * itself on every start would re-read every render in the wiki to produce what is usually the same + * rows. + * + * **In a worker thread**, which is what this file being here means — `addJob` sends a task with no + * in-process implementation to the pool. A wiki's pages are its whole content, and parsing every + * render in the instance is minutes of CPU on a large one: on the main thread that is the event loop + * not serving pages for the duration. + * + * The price is that a worker starts with nothing but config and a logger (see `worker.ts`), and + * resolving a link needs rather more than that — which is what `prepare` below is for. + */ + +/** How many pages are read at once. A render is a whole page of HTML, so this is a memory ceiling. */ +const BATCH_SIZE = 50 + +/** + * Put back the parts of the `WIKI` global that reading a link needs. + * + * A worker thread is deliberately bare, and what an href means is decided against a good deal of + * instance state: which hostname belongs to which site, what a site's locale prefixes and page + * extensions are, and what short code each locale answers to. All of it is loaded here exactly as + * `postBoot` loads it on the main thread. + * + * Only the four models involved, rather than the whole registry — `WIKI.models` is what `reloadCache` + * and the resolver reach through, and importing the rest would pull the storage, search and mail + * models into a thread that is reading HTML. + */ +async function prepare(): Promise { + await WIKI.ensureDb!() + WIKI.cache = new NodeCache({ checkperiod: 0 }) + WIKI.models = { settings, locales, pages, pageLinks } as typeof WIKI.models + // -> Locales first: the site cache reads nothing from them, but a site's URL prefixes are resolved + // through the locale cache the moment the first link is read + await locales.reloadCache() + await sites.reloadCache() +} + +export async function task(): Promise { + await prepare() + + let cursor: string | null = null + let processed = 0 + + WIKI.logger.info('Rebuilding page links...') + for (;;) { + /* + Walked by id rather than by offset: this reads every page in the wiki one batch at a time, and a + paged read that re-counts its way to each batch gets slower as it goes. Nothing is being written + to `pages` here, so the set is stable underneath it. + */ + const rows = await WIKI.db + .select({ + id: pagesTable.id, + siteId: pagesTable.siteId, + locale: pagesTable.locale, + path: pagesTable.path, + editor: pagesTable.editor, + content: pagesTable.content, + relations: pagesTable.relations, + render: pagesTable.render + }) + .from(pagesTable) + .where(cursor ? gt(pagesTable.id, cursor) : undefined) + .orderBy(asc(pagesTable.id)) + .limit(BATCH_SIZE) + + if (rows.length < 1) { + break + } + for (const row of rows) { + /* + One page at a time, and one failure does not stop the rest: this is a repair, and stopping at + the first page whose render will not parse would leave the wiki with the half of its links it + already had plus however far this got. + */ + try { + await pageLinks.refreshForPage(row) + } catch (err: any) { + WIKI.logger.warn(`Could not rebuild the links of page ${row.id}: ${err.message}`) + } + processed++ + } + cursor = rows.at(-1)!.id + } + + WIKI.logger.info(`Rebuilt the links of ${processed} page(s) [ COMPLETED ]`) +} diff --git a/frontend/src/components/PageLinks.vue b/frontend/src/components/PageLinks.vue new file mode 100644 index 000000000..0fc5f885d --- /dev/null +++ b/frontend/src/components/PageLinks.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/frontend/src/components/PagePropertiesDialog.vue b/frontend/src/components/PagePropertiesDialog.vue index 2a4fb948d..4ad6ef40f 100644 --- a/frontend/src/components/PagePropertiesDialog.vue +++ b/frontend/src/components/PagePropertiesDialog.vue @@ -165,6 +165,22 @@ @click="state.showLocaleRelationsDialog = true"> {{ t('editor.props.localeRelationsHint') }} + +
+ +
{{ t('admin.general.features') }} + + + + + {{ t(`admin.general.allowBacklinks`) }} + {{ t(`admin.general.allowBacklinksHint`) }} + + + + + + @@ -652,6 +670,15 @@ function defaultConfig() { follow: false }, features: { + backlinks: true, + /* + False, where the site's own default is true, because this is what the SERVER makes of the key + being absent: `controllers/collab.ts` tests it for truthiness, so a config blob without it is + a site where collaboration is off, and the toggle has to say so rather than offer to turn off + something that already is. `backlinks` and `comments` above default the other way for the same + reason read the other way -- both are `!== false` on the server. + */ + collaborativeEditing: false, ratings: false, ratingsMode: 'off', comments: true, @@ -780,7 +807,12 @@ async function save() { follow: state.config.robots?.follow ?? false }, features: { + backlinks: state.config.features?.backlinks ?? true, browse: state.config.features?.browse ?? false, + // -> The site config is deep-merged on the server, so a key left out of this object keeps + // whatever is stored rather than being dropped. That is what made leaving this one out + // silent: the toggle moved, the save succeeded, and the next load put it back. + collaborativeEditing: state.config.features?.collaborativeEditing ?? false, comments: state.config.features?.comments ?? true, ratingsMode: state.config.features?.ratingsMode ?? 'off', reasonForChange: state.config.features?.reasonForChange ?? 'required', diff --git a/frontend/src/pages/AdminUtilities.vue b/frontend/src/pages/AdminUtilities.vue index 1c027e66e..354e9a254 100644 --- a/frontend/src/pages/AdminUtilities.vue +++ b/frontend/src/pages/AdminUtilities.vue @@ -225,6 +225,22 @@ :label="t(`common.actions.proceed`)" /> + + + + {{ t(`admin.utilities.rebuildPageLinks`) }} + {{ t(`admin.utilities.rebuildPageLinksHint`) }} + + + + + @@ -702,6 +718,44 @@ async function purgeSampleContent() { }) } +/** + * Work out again what every page on every wiki links to, from the render each page already stores. + * + * Queued rather than done in the request: it reads every page in the instance, so it runs in a worker + * thread and the response only says it has been scheduled. Confirmed even so — it is a lot of work on + * a large wiki — but not coloured as a destruction, since nothing about the content changes and the + * rows it rewrites are derived from that content anyway. + */ +function rebuildPageLinks() { + confirm({ + title: t('admin.utilities.rebuildPageLinks'), + message: t('admin.utilities.rebuildPageLinksConfirm'), + caption: t('admin.utilities.rebuildPageLinksConfirmWarn'), + cancel: true, + persistent: true, + okLabel: t('common.actions.proceed') + }).onOk(async () => { + loading.show() + try { + const resp = await API_CLIENT.post('system/page-links/rebuild').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + notify({ + type: 'positive', + message: t('admin.utilities.rebuildPageLinksSuccess') + }) + } catch (err) { + notify({ + type: 'negative', + message: t('admin.utilities.rebuildPageLinksFailed'), + caption: apiErrorMessage(err) + }) + } + loading.hide() + }) +} + /** * Throw away everything the wiki has cached off the database — files, icons, and the site, group and * locale state read on every request. Not confirmed: nothing is lost and nothing stops working, the diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index 3e8f50443..b5c086b71 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -51,16 +51,25 @@ class="min-w-0 flex-1 flex flex-col min-h-0" :style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`"> - + - + +