From 7c6a3bb59087b7212b7d51d3dc86fcb92db33bd0 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Wed, 23 Sep 2026 08:21:47 -0400 Subject: [PATCH] feat: import from wiki.js v2 utility --- backend/api/import.ts | 358 + backend/api/index.ts | 2 + backend/api/pages.ts | 25 +- backend/api/schemas/import.ts | 97 + .../20260922154423_scarlett/migration.sql | 27 + .../20260922154423_scarlett/snapshot.json | 7223 +++++++++++++++++ backend/db/schema.ts | 86 + backend/locales/en.json | 3 +- backend/models/assets.ts | 48 +- backend/models/auditLog.ts | 2 + backend/models/groups.ts | 55 + backend/models/import.ts | 1876 +++++ backend/models/index.ts | 2 + backend/models/jobs.ts | 2 + backend/models/pages.ts | 25 +- backend/tasks/simple/purge-import-sessions.ts | 24 + dev/specs/wkbackup.md | 368 +- frontend/package-lock.json | 12 + frontend/package.json | 1 + frontend/src/assets/icons.generated.js | 3 +- .../src/components/ImportWikijs2Overlay.vue | 321 +- .../src/components/shared/WLinearProgress.vue | 122 +- frontend/src/helpers/wkbackup.js | 188 + frontend/src/helpers/wkbackupImport.js | 425 + 24 files changed, 11171 insertions(+), 124 deletions(-) create mode 100644 backend/api/import.ts create mode 100644 backend/api/schemas/import.ts create mode 100644 backend/db/migrations/20260922154423_scarlett/migration.sql create mode 100644 backend/db/migrations/20260922154423_scarlett/snapshot.json create mode 100644 backend/models/import.ts create mode 100644 backend/tasks/simple/purge-import-sessions.ts create mode 100644 frontend/src/helpers/wkbackup.js create mode 100644 frontend/src/helpers/wkbackupImport.js diff --git a/backend/api/import.ts b/backend/api/import.ts new file mode 100644 index 000000000..09896fc0d --- /dev/null +++ b/backend/api/import.ts @@ -0,0 +1,358 @@ +import type { FastifyInstance } from 'fastify' +import { audit } from '../helpers/audit.ts' +import { + IMPORT_CONTENT_KINDS, + MAX_BATCH_BYTES, + MAX_BATCH_RECORDS, + MAX_BLOB_BYTES, + type ImportSessionSite +} from '../models/import.ts' + +/** + * Import API Routes + * + * `dev/specs/wkbackup.md` §7 is the design. The package is never uploaded: the browser holds it, + * walks it, and drives these routes a batch at a time. + * + * **Every route takes `manage:system`**, and that is the spec's §5 rather than laziness. An import + * writes groups and puts accounts into them, which is precisely the escalation `elevatedGroupGuard` + * exists to stop; anything narrower would have to re-implement those guards per record and would + * still add up to a route to every permission on the wiki. A dedicated `manage:import` would be a new + * global permission, which is the maintainer's call and not something to introduce alongside a + * feature. + * + * The session is instance-level rather than under `/sites/:siteId`, because half of what it writes + * belongs to no site. + */ +async function routes(app: FastifyInstance) { + /* + A blob is the raw bytes rather than a multipart form: one file per request, named by its own + SHA-256 in the path. The catch-all only claims content types nothing else parses, so the JSON + routes below are unaffected. + */ + app.addContentTypeParser( + '*', + { parseAs: 'buffer', bodyLimit: MAX_BLOB_BYTES }, + (req, body, done) => { + done(null, body) + } + ) + + /** + * PREFLIGHT A PACKAGE + */ + app.post<{ Body: { manifest: any; siteId: string } }>( + '/import/preflight', + { + config: { permissions: ['manage:system'] }, + schema: { + summary: 'Check a backup package before importing it', + description: + 'Everything answerable from `manifest.json` alone — the container version, the source kind, that every stream it promises has a path and a schema this wiki can read, and that the target site exists. The browser has the manifest in hand within a second of opening the file, whatever the rest of it weighs, so this is what fails an unreadable eight-gigabyte package immediately rather than forty minutes in.\n\n`errors` stops the import; `warnings` are things it will carry on past and the operator should see first — including anything the exporter itself could not represent.', + tags: ['Import'], + body: { + type: 'object', + properties: { + manifest: { type: 'object', additionalProperties: true }, + siteId: { type: 'string', format: 'uuid' } + }, + required: ['manifest', 'siteId'] + }, + response: { + 200: { $ref: 'ImportPreflight#' } + } + } + }, + async (req) => { + return WIKI.models.importer.preflight(req.body.manifest, req.body.siteId) + } + ) + + /** + * CREATE AN IMPORT SESSION + */ + app.post<{ + Body: { + source: string + sourceInstanceId?: string + sites: ImportSessionSite[] + includes: string[] + overwrite?: boolean + } + }>( + '/import/sessions', + { + config: { permissions: ['manage:system'] }, + schema: { + summary: 'Open an import session', + description: + 'Settles once what every later batch depends on: which target site each package site lands in, what the operator chose to bring over, and whether an existing record is replaced.\n\nThe session is a row rather than server memory, so a batch may be answered by any instance of an HA set and a browser that was interrupted can ask where it got to.\n\nIt also carries the UUID namespace this import derives record ids in, and that namespace is a function of the source wiki and the target site rather than of the session — so an import that fell over can simply be run again from the top without laying down a second copy of every comment and every history entry.', + tags: ['Import'], + body: { + type: 'object', + properties: { + source: { + type: 'string', + description: '`manifest.source.kind`. Only `wikijs2` is implemented.' + }, + sourceInstanceId: { + type: 'string', + description: + '`manifest.source.instanceId`. Part of the namespace this import derives record ids in, so that two different source wikis imported into one site cannot derive the same id for two different comments.' + }, + sites: { + type: 'array', + items: { + type: 'object', + properties: { + sourceId: { type: 'string' }, + siteId: { type: 'string', format: 'uuid' } + }, + required: ['sourceId', 'siteId'] + } + }, + includes: { + type: 'array', + items: { type: 'string', enum: [...IMPORT_CONTENT_KINDS] }, + description: + 'Settings are not importable — which 2.x key means what in 3.x is being settled separately.' + }, + overwrite: { type: 'boolean', default: false } + }, + required: ['source', 'sites', 'includes'] + }, + response: { + 200: { $ref: 'ImportSession#' } + } + } + }, + async (req) => { + const session = await WIKI.models.importer.createSession({ + source: req.body.source, + sourceInstanceId: req.body.sourceInstanceId, + sites: req.body.sites, + includes: req.body.includes, + overwrite: req.body.overwrite === true, + actorId: req.session?.user?.id ?? null + }) + await audit(req, 'admin', 'startImport', { + sessionId: session.id, + source: session.source, + includes: session.includes, + overwrite: session.overwrite, + siteIds: session.sites.map((entry) => entry.siteId) + }) + return session + } + ) + + /** + * READ AN IMPORT SESSION + */ + app.get<{ Params: { sessionId: string } }>( + '/import/sessions/:sessionId', + { + config: { permissions: ['manage:system'] }, + schema: { + summary: 'Read an import session', + description: + 'What has been written so far, per stream, and everything the import could not carry. This is what a browser that was interrupted reads to find its place: records are keyed so that replaying one is an upsert, so resuming is replaying from the last counted batch.', + tags: ['Import'], + params: { + type: 'object', + properties: { sessionId: { type: 'string', format: 'uuid' } }, + required: ['sessionId'] + }, + response: { + 200: { $ref: 'ImportSession#' } + } + } + }, + async (req, reply) => { + const session = await WIKI.models.importer.getSession(req.params.sessionId) + if (!session) { + return reply.notFound('No such import session.') + } + return session + } + ) + + /** + * UPLOAD A BLOB + */ + app.post<{ Params: { sessionId: string; digest: string } }>( + '/import/sessions/:sessionId/blobs/:digest', + { + config: { permissions: ['manage:system'] }, + schema: { + summary: 'Upload one of a package’s blobs', + description: `The body is the file itself, not a multipart form — send the bytes with \`Content-Type: application/octet-stream\`. At most ${MAX_BLOB_BYTES / 1024 / 1024 / 1024} GB.\n\nThe path segment is the file's SHA-256, which is also what names it inside the package, and it is **verified** rather than trusted: the name being the checksum is the whole of what content addressing establishes.\n\nBlobs are uploaded before the metadata that references them, and once each however many records point at the same bytes. They wait in a staging directory that \`finish\` removes.`, + tags: ['Import'], + consumes: ['application/octet-stream'], + params: { + type: 'object', + properties: { + sessionId: { type: 'string', format: 'uuid' }, + digest: { type: 'string', pattern: '^[0-9a-f]{64}$' } + }, + required: ['sessionId', 'digest'] + }, + response: { + 200: { + description: 'Blob staged', + type: 'object', + properties: { + ok: { type: 'boolean' }, + bytes: { type: 'integer' } + } + } + } + } + }, + async (req, reply) => { + await WIKI.models.importer.requireOpenSession(req.params.sessionId) + const data = req.body + if (!Buffer.isBuffer(data) || data.length < 1) { + return reply.badRequest('No file was sent.') + } + const { bytes } = await WIKI.models.importer.putBlob( + req.params.sessionId, + req.params.digest, + data + ) + return { ok: true, bytes } + } + ) + + /** + * INGEST AN INSTANCE-WIDE STREAM + */ + app.post<{ Params: { sessionId: string; stream: string }; Body: { records: any[] } }>( + '/import/sessions/:sessionId/streams/:stream', + { + config: { permissions: ['manage:system'] }, + // -> Above the instance-wide JSON limit; see `MAX_BATCH_BYTES` + bodyLimit: MAX_BATCH_BYTES, + schema: { + summary: 'Write a batch of instance-wide records', + description: `Users, groups and locales belong to the wiki rather than to any one site, which is why the session is not itself under a site.\n\nAt most ${MAX_BATCH_RECORDS} records and ${MAX_BATCH_BYTES / 1024 / 1024} MB per request. A stream whose content kind the session was not opened for is refused rather than ignored, and a stream name this wiki has no reader for is refused too — a batch that was quietly dropped is a migration that quietly lost something.`, + tags: ['Import'], + params: { + type: 'object', + properties: { + sessionId: { type: 'string', format: 'uuid' }, + stream: { type: 'string', enum: ['locales', 'groups', 'users'] } + }, + required: ['sessionId', 'stream'] + }, + body: { + type: 'object', + properties: { + records: { type: 'array', items: { type: 'object', additionalProperties: true } } + }, + required: ['records'] + }, + response: { + 200: { $ref: 'ImportBatch#' } + } + } + }, + async (req) => { + return WIKI.models.importer.ingest({ + sessionId: req.params.sessionId, + stream: req.params.stream, + records: req.body.records + }) + } + ) + + /** + * INGEST A SITE STREAM + */ + app.post<{ + Params: { sessionId: string; siteId: string; stream: string } + Body: { records: any[] } + }>( + '/import/sessions/:sessionId/sites/:siteId/streams/:stream', + { + config: { permissions: ['manage:system'] }, + /* + Above the instance-wide JSON limit, and this is the route that needs it: a batch of pages or + of page history is a batch of whole documents, where a batch of anything else is a batch of + rows. See `MAX_BATCH_BYTES`. + */ + bodyLimit: MAX_BATCH_BYTES, + schema: { + summary: 'Write a batch of records belonging to one site', + description: `The site is a target site on THIS instance, one the session was opened for — a package site the operator did not map is never read.\n\nAt most ${MAX_BATCH_RECORDS} records and ${MAX_BATCH_BYTES / 1024 / 1024} MB per request; a batch of pages or of page history is a batch of whole documents, so the byte ceiling is usually what a caller meets first.\n\nOrder matters between these streams and is the caller's to keep: folders, then blobs, then pages, then the history and comments that hang off them, then the assets those blobs belong to, then navigation. A record whose page was not imported is skipped rather than failing its batch.`, + tags: ['Import'], + params: { + type: 'object', + properties: { + sessionId: { type: 'string', format: 'uuid' }, + siteId: { type: 'string', format: 'uuid' }, + stream: { + type: 'string', + enum: ['tree', 'pages', 'page-history', 'assets', 'comments', 'navigation'] + } + }, + required: ['sessionId', 'siteId', 'stream'] + }, + body: { + type: 'object', + properties: { + records: { type: 'array', items: { type: 'object', additionalProperties: true } } + }, + required: ['records'] + }, + response: { + 200: { $ref: 'ImportBatch#' } + } + } + }, + async (req) => { + return WIKI.models.importer.ingest({ + sessionId: req.params.sessionId, + stream: req.params.stream, + siteId: req.params.siteId, + records: req.body.records + }) + } + ) + + /** + * FINISH AN IMPORT SESSION + */ + app.post<{ Params: { sessionId: string } }>( + '/import/sessions/:sessionId/finish', + { + config: { permissions: ['manage:system'] }, + schema: { + summary: 'Close an import session', + description: + 'Drops the staged blobs and reports what happened. Nothing is rebuilt: every model the records went through did its own bookkeeping as it wrote — the tree entry, the storage-target copies, the search index, the queued render.\n\nThe two counts at the end are what an operator has to know about a 2.x import. A page arrives with no HTML, because a 2.x render is 2.x’s output and would be wrong here in ways nothing could later detect, so it carries a placeholder until the render queue reaches it — one headless browser, one page at a time. `unrenderable` is the pages this wiki has no server-side renderer for at all; those keep their placeholder until somebody opens and saves them, which is a to-do list rather than a failure.', + tags: ['Import'], + params: { + type: 'object', + properties: { sessionId: { type: 'string', format: 'uuid' } }, + required: ['sessionId'] + }, + response: { + 200: { $ref: 'ImportSummary#' } + } + } + }, + async (req) => { + const summary = await WIKI.models.importer.finishSession(req.params.sessionId) + await audit(req, 'admin', 'finishImport', { + sessionId: req.params.sessionId, + progress: summary.progress, + pendingRenders: summary.pendingRenders, + unrenderable: summary.unrenderable + }) + return summary + } + ) +} + +export default routes diff --git a/backend/api/index.ts b/backend/api/index.ts index 8cf3328c5..67f554955 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -18,6 +18,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/hook.ts').then((m) => m.registerSchemas(app)) await import('./schemas/icon.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/import.ts').then((m) => m.registerSchemas(app)) await import('./schemas/locale.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app)) @@ -44,6 +45,7 @@ async function routes(app: FastifyInstance) { app.register(import('./groups.ts'), { prefix: '/groups' }) app.register(import('./hooks.ts'), { prefix: '/hooks' }) app.register(import('./icons.ts'), { prefix: '/icons' }) + app.register(import('./import.ts')) app.register(import('./locales.ts'), { prefix: '/locales' }) app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./navigation.ts')) diff --git a/backend/api/pages.ts b/backend/api/pages.ts index e666ca97e..48f8bff2b 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -2,6 +2,7 @@ import { validate as uuidValidate } from 'uuid' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { PageActor, PageInput } from '../models/pages.ts' import type { RulePageRef } from '../helpers/pageRules.ts' +import { PAGE_PERMISSIONS } from '../models/groups.ts' import { SEARCH_ORDER_BY, SEARCH_TAGS_MATCH, @@ -90,30 +91,6 @@ export function actorFrom(req: FastifyRequest): PageActor | null { */ const PASSWORD_BYPASS = ['write:pages', 'manage:pages', 'manage:system'] -/** - * Every page permission a rule can grant, i.e. the whole set `manage:system` amounts to. Mirrors the - * page rules offered in the group editor, and is what the interface asks about per path. - */ -const PAGE_PERMISSIONS = [ - 'read:pages', - 'write:pages', - 'review:pages', - 'manage:pages', - 'delete:pages', - 'write:tags', - 'write:styles', - 'write:scripts', - 'read:source', - 'read:history', - 'read:assets', - 'write:assets', - 'manage:assets', - 'read:comments', - 'write:comments', - 'manage:comments', - 'manage:navigation' -] - export function mayBypassPassword(req: FastifyRequest): boolean { const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? [] return PASSWORD_BYPASS.some((permission) => permissions.includes(permission)) diff --git a/backend/api/schemas/import.ts b/backend/api/schemas/import.ts new file mode 100644 index 000000000..5819efd01 --- /dev/null +++ b/backend/api/schemas/import.ts @@ -0,0 +1,97 @@ +import type { FastifyInstance } from 'fastify' + +/** + * Shared schemas for the import API. + * + * The record streams themselves are deliberately NOT schema'd here. What a `.wkbackup` line looks + * like is `dev/specs/wkbackup.md` §12's business, it varies per stream and per `schema` version, and + * the models are what read it — validating it twice, in a shape that has to be kept in step with a + * document in another repository, would buy a worse error message than the importer's own. + */ +export function registerSchemas(app: FastifyInstance) { + app.addSchema({ + $id: 'ImportPreflight', + type: 'object', + properties: { + ok: { type: 'boolean', description: 'False when `errors` is non-empty.' }, + errors: { + type: 'array', + items: { type: 'string' }, + description: 'Reasons the package cannot be imported at all.' + }, + warnings: { + type: 'array', + items: { type: 'string' }, + description: + 'Things the import will carry on past, including anything the exporter itself reported it could not represent.' + } + } + }) + + app.addSchema({ + $id: 'ImportSession', + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + namespace: { + type: 'string', + format: 'uuid', + description: 'The UUIDv5 namespace this import derives its record ids in.' + }, + source: { type: 'string' }, + sites: { + type: 'array', + items: { + type: 'object', + properties: { + sourceId: { type: 'string' }, + siteId: { type: 'string', format: 'uuid' } + } + } + }, + includes: { type: 'array', items: { type: 'string' } }, + overwrite: { type: 'boolean' }, + state: { type: 'string', enum: ['open', 'finished', 'failed'] }, + progress: { + type: 'object', + additionalProperties: { type: 'integer' }, + description: 'Records written so far, per stream.' + }, + warnings: { type: 'array', items: { type: 'string' } }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' } + } + }) + + app.addSchema({ + $id: 'ImportBatch', + type: 'object', + properties: { + imported: { type: 'integer' }, + skipped: { + type: 'integer', + description: + 'Records the import passed over — already present with overwrite off, missing a page to hang off, or incomplete. Never a failure.' + }, + warnings: { type: 'array', items: { type: 'string' } } + } + }) + + app.addSchema({ + $id: 'ImportSummary', + type: 'object', + properties: { + progress: { type: 'object', additionalProperties: { type: 'integer' } }, + warnings: { type: 'array', items: { type: 'string' } }, + pendingRenders: { + type: 'integer', + description: 'Imported pages waiting on the render queue.' + }, + unrenderable: { + type: 'integer', + description: + 'Imported pages this wiki has no server-side renderer for. They keep their placeholder until somebody opens and saves them.' + } + } + }) +} diff --git a/backend/db/migrations/20260922154423_scarlett/migration.sql b/backend/db/migrations/20260922154423_scarlett/migration.sql new file mode 100644 index 000000000..0a87b33ee --- /dev/null +++ b/backend/db/migrations/20260922154423_scarlett/migration.sql @@ -0,0 +1,27 @@ +CREATE TYPE "importSessionState" AS ENUM('open', 'finished', 'failed');--> statement-breakpoint +CREATE TABLE "importIdMap" ( + "sessionId" uuid, + "entity" varchar(32), + "sourceId" varchar(255), + "targetId" uuid NOT NULL, + CONSTRAINT "importIdMap_pkey" PRIMARY KEY("sessionId","entity","sourceId") +); +--> statement-breakpoint +CREATE TABLE "importSessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "namespace" uuid NOT NULL, + "source" varchar(32) NOT NULL, + "sites" jsonb DEFAULT '[]' NOT NULL, + "includes" jsonb DEFAULT '[]' NOT NULL, + "overwrite" boolean DEFAULT false NOT NULL, + "state" "importSessionState" DEFAULT 'open'::"importSessionState" NOT NULL, + "progress" jsonb DEFAULT '{}' NOT NULL, + "warnings" jsonb DEFAULT '[]' NOT NULL, + "actorId" uuid, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "importSessions_createdAt_idx" ON "importSessions" ("createdAt");--> statement-breakpoint +ALTER TABLE "importIdMap" ADD CONSTRAINT "importIdMap_sessionId_importSessions_id_fkey" FOREIGN KEY ("sessionId") REFERENCES "importSessions"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "importSessions" ADD CONSTRAINT "importSessions_actorId_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/backend/db/migrations/20260922154423_scarlett/snapshot.json b/backend/db/migrations/20260922154423_scarlett/snapshot.json new file mode 100644 index 000000000..b978fa27d --- /dev/null +++ b/backend/db/migrations/20260922154423_scarlett/snapshot.json @@ -0,0 +1,7223 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "be61a0e3-2843-4169-a4fb-d3f6a4033896", + "prevIds": [ + "24917438-5701-4cfd-8a15-25bd9a55215e" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "open", + "finished", + "failed" + ], + "name": "importSessionState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "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": "importIdMap", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "importSessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "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": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "packageData", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "checksum", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "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": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sessionId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "importIdMap" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "namespace", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "sites", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "includes", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "overwrite", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "importSessionState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "progress", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "warnings", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "importSessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": 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": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "groups_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "groups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "importSessions_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "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": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"handle\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "sessionId" + ], + "schemaTo": "public", + "tableTo": "importSessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "importIdMap_sessionId_importSessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importIdMap" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "importSessions_actorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "importSessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "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": [ + "sessionId", + "entity", + "sourceId" + ], + "nameExplicit": false, + "name": "importIdMap_pkey", + "entityType": "pks", + "schema": "public", + "table": "importIdMap" + }, + { + "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": "importSessions_pkey", + "schema": "public", + "table": "importSessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "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 d47320cfe..6f5c2b3ec 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -411,6 +411,92 @@ export const icons = pgTable( (table) => [primaryKey({ columns: [table.prefix, table.name] })] ) +// IMPORT SESSIONS --------------------- +/** + * One run of **Administration → Utilities → Import from Wiki.js 2.x**, from the moment the operator + * presses Start until the package has been walked. + * + * A row rather than memory, for two reasons that both come from the import living in a browser tab: + * in an HA set the next batch is answered by a different instance, and a tab that closed has to be + * able to say where it got to. See `dev/specs/wkbackup.md` §7. + * + * Everything the handlers need to agree about across thousands of requests is here — which target + * site each package site lands in, what the operator ticked, and the group mapping the user records + * resolve through — so a batch carries only its own records. + */ +export const importSessionStateEnum = pgEnum('importSessionState', ['open', 'finished', 'failed']) +export const importSessions = pgTable( + 'importSessions', + { + id: uuid().primaryKey().defaultRandom(), + /** + * The UUIDv5 namespace the derived ids of this import are built in. + * + * **Derived, not random** — from the source wiki and the site being imported into, so that the + * same package run into the same site a second time derives the same ids and upserts, while two + * operators importing two different packages cannot collide. It has to survive the SESSION and + * not just outlive a batch: an import that fell over is re-run from the top, which would + * otherwise mean a second copy of every comment and every history entry — the two record kinds + * with no natural key to match on. See `dev/specs/wkbackup.md` §4. + */ + namespace: uuid().notNull(), + /** `manifest.source.kind`. Only `wikijs2` is implemented. */ + source: varchar({ length: 32 }).notNull(), + /** `[{ sourceId, siteId }]` — each package site paired with the target site it lands in. */ + sites: jsonb().notNull().default([]), + /** Which content kinds the operator ticked. A stream for anything absent is refused. */ + includes: jsonb().notNull().default([]), + overwrite: boolean().notNull().default(false), + state: importSessionStateEnum().notNull().default('open'), + /** `{ : }`, which is what a resumed tab reads to find its place. */ + progress: jsonb().notNull().default({}), + /** Everything the import could not carry, in the order it was found. Shown in the log. */ + warnings: jsonb().notNull().default([]), + /** + * Who is running it. Null once that account is gone, which costs nothing: a finished session is + * a receipt, and the audit log is where the act itself is recorded. + * + * Also what `usersStream` compares each record against — the account running the import is never + * written to, whatever `overwrite` says. + */ + actorId: uuid().references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow() + }, + (table) => [index('importSessions_createdAt_idx').on(table.createdAt)] +) + +/** + * What a record from the source wiki became here — its 2.x integer id paired with the row it is now. + * + * A table rather than a blob on the session, because the things that have to be looked up this way + * are unbounded: a page's author is a 2.x user id, a comment names its page by 2.x page id, and a + * wiki has as many of those as it has users and pages. A jsonb column rewritten once per batch would + * be megabytes of write amplification by the end of a large import, where this is an insert per + * record and one indexed read per batch. + * + * It exists because the package speaks 2.x's ids and this wiki matches on natural keys — a user by + * email, a page by path. Those two answers have to be joined up somewhere, and only for the entities + * something actually references: users, pages and groups. + * + * Rows go with the session, which is what stops this becoming a permanent record of somebody's old + * instance. + */ +export const importIdMap = pgTable( + 'importIdMap', + { + sessionId: uuid() + .notNull() + .references(() => importSessions.id, { onDelete: 'cascade' }), + /** `user`, `page` or `group`. */ + entity: varchar({ length: 32 }).notNull(), + /** The id the source wiki knew it by, as text — 2.x numbers them, a 3.x package would not. */ + sourceId: varchar({ length: 255 }).notNull(), + targetId: uuid().notNull() + }, + (table) => [primaryKey({ columns: [table.sessionId, table.entity, table.sourceId] })] +) + // JOB HISTORY ------------------------- export const jobHistoryStateEnum = pgEnum('jobHistoryState', [ 'active', diff --git a/backend/locales/en.json b/backend/locales/en.json index 05b14f464..4af8cb4df 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1517,7 +1517,9 @@ "admin.utilities.wikijs2Import.comments": "Comments", "admin.utilities.wikijs2Import.groups": "Groups", "admin.utilities.wikijs2Import.history": "History", + "admin.utilities.wikijs2Import.inProgress": "Import in progress...", "admin.utilities.wikijs2Import.navigation": "Navigation", + "admin.utilities.wikijs2Import.logStarted": "Reading {file}...", "admin.utilities.wikijs2Import.noArchive": "No archive selected", "admin.utilities.wikijs2Import.options": "Options", "admin.utilities.wikijs2Import.overwrite": "Overwrite on conflict", @@ -1527,7 +1529,6 @@ "admin.utilities.wikijs2Import.progress": "Progress Log", "admin.utilities.wikijs2Import.progressEmpty": "The import has not been started. Progress will be reported here.", "admin.utilities.wikijs2Import.selectArchive": "Select archive...", - "admin.utilities.wikijs2Import.settings": "Settings", "admin.utilities.wikijs2Import.source": "Destination", "admin.utilities.wikijs2Import.start": "Start Import", "admin.utilities.wikijs2Import.subtitle": "Migrate content from a Wiki.js 2.x backup", diff --git a/backend/models/assets.ts b/backend/models/assets.ts index 3af621384..1772d8dc2 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -868,13 +868,21 @@ class Assets { } /** - * Take a file a storage target already holds into the wiki, without writing it anywhere. + * Take a file into the wiki that the wiki has no record of. * - * The other direction from an upload: the bytes are already in place — restored from a backup, - * dropped into the folder by another tool — and what is missing is the wiki's record of them. A file - * the wiki has no entry for is adopted where it lies rather than written out again, which is why - * nothing is dispatched to the storage layer for it. Any *other* target configured to hold that kind - * will not have a copy until its own export action runs. + * The other direction from an upload, and it serves two callers that differ in one thing: whether + * anybody is already holding the bytes. + * + * - **A storage target's import** walks a folder the bytes are already in — restored from a backup, + * dropped there by another tool — and what is missing is only the wiki's record of them. Adopted + * where they lie, with nothing dispatched to the storage layer, which is the default. + * - **A package import** (`.wkbackup`) reads them out of a file in the operator's browser. Nobody + * holds them, so `dispatch` writes them to every target the site stores that kind on, exactly as + * an upload would. Without it the asset gets a row and a thumbnail and no bytes anywhere — a file + * the file manager lists, previews, and cannot open. + * + * Either way, any *other* target configured to hold that kind and not written to here will not have + * a copy until its own export action runs. * * `overwrite` turns the case the wiki DOES have an entry for from a skip into a replacement, for a * restore where the folder is meant to be the authority. That one is dispatched, and has to be: the @@ -887,6 +895,8 @@ class Assets { * loose file in a folder may take over. * * @param overwrite Replace an asset already at this path instead of leaving it alone + * @param dispatch Write the bytes to the site's storage targets, for a caller holding bytes nobody + * else has. The replacement path dispatches regardless, since it has to. * @returns The asset, or null for a file this passed over */ async adoptStoredFile({ @@ -896,7 +906,8 @@ class Assets { fileName, data, authorId, - overwrite + overwrite, + dispatch }: { siteId: string locale: string @@ -905,6 +916,7 @@ class Assets { data: Buffer authorId: string overwrite?: boolean + dispatch?: boolean }): Promise { const safeName = sanitizeFileName(fileName) if (!safeName) { @@ -973,8 +985,12 @@ class Assets { siteId, meta: { fileSize: data.length, fileExt, mimeType, ...dimensionMeta(dimensions) } }) + // -> Read off the row rather than from the caller: the folder may have just been created, and a + // name that was taken took the next free one + const importedFolderPath = decodeTreePath(entry.folderPath ?? '') ?? '' try { + // -> The metadata row goes in before the bytes, since the database target writes them into it await WIKI.db.insert(assetsTable).values({ id: entry.id, fileName: entry.fileName, @@ -987,12 +1003,28 @@ class Assets { authorId, siteId }) + if (dispatch) { + await WIKI.models.storage.putAsset( + { + id: entry.id, + siteId, + actorId: authorId, + locale, + folderPath: importedFolderPath, + fileName: entry.fileName, + kind, + fileSize: data.length + }, + data + ) + } } catch (err) { + // -> Nothing points at these now, and leaving them would show a file the site cannot serve + await WIKI.db.delete(assetsTable).where(eq(assetsTable.id, entry.id)) await WIKI.db.delete(treeTable).where(eq(treeTable.id, entry.id)) throw err } - const importedFolderPath = decodeTreePath(entry.folderPath ?? '') ?? '' WIKI.models.hooks.emit('asset:upload', { id: entry.id, fileName: entry.fileName, diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 3b9030980..15b72ffd9 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -121,6 +121,8 @@ export const AUDIT_ACTIONS = { 'rebuildSearchIndex', 'rebuildPageLinks', 'installExtension', + 'startImport', + 'finishImport', 'updateApiState', 'updateMetricsState', 'updateScimState', diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 0dae71608..028817a08 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -39,6 +39,61 @@ export const ELEVATED_PERMISSIONS = [ SYSTEM_PERMISSION ] as const +/** + * Every GLOBAL permission a group may hold — the site-wide list, bound to no path. + * + * Here rather than in the admin screen that offers them or the route hook that checks them, because + * it is a vocabulary rather than a UI concern: nothing validates a permission string on the way in, + * so a name that is not on this list simply never matches anything, and whoever needs to ask "is + * this a real permission" needs one list to ask. `helpers/pageRules.ts` documents the other kind. + * + * Adding a name is the maintainer's call — see CLAUDE.md. + */ +export const GLOBAL_PERMISSIONS = [ + 'access:admin', + 'read:users', + 'write:users', + 'manage:users', + 'read:groups', + 'write:groups', + 'manage:groups', + 'read:audit', + 'read:metrics', + 'manage:theme', + 'manage:storage', + 'manage:sites', + 'read:webhooks', + 'manage:webhooks', + 'manage:scim', + SYSTEM_PERMISSION +] as const + +/** + * Every PAGE permission a rule may grant — bound to a path, a locale and a site. + * + * The other half of the vocabulary above, and deliberately disjoint from it: `config.permissions` on + * a route reads the global list only, so naming one of these there refuses everybody. + */ +export const PAGE_PERMISSIONS = [ + 'read:pages', + 'write:pages', + 'review:pages', + 'manage:pages', + 'delete:pages', + 'write:tags', + 'write:styles', + 'write:scripts', + 'read:source', + 'read:history', + 'read:assets', + 'write:assets', + 'manage:assets', + 'read:comments', + 'write:comments', + 'manage:comments', + 'manage:navigation' +] + /** Whether a permission list carries any of `ELEVATED_PERMISSIONS`. */ export function isElevated(permissions: readonly string[]): boolean { return permissions.some((permission) => diff --git a/backend/models/import.ts b/backend/models/import.ts new file mode 100644 index 000000000..9ec5a8fa6 --- /dev/null +++ b/backend/models/import.ts @@ -0,0 +1,1876 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { v5 as uuidv5 } from 'uuid' +import { and, eq, inArray, lt, sql } from 'drizzle-orm' +import { + comments as commentsTable, + groups as groupsTable, + importIdMap as importIdMapTable, + importSessions as importSessionsTable, + navigation as navigationTable, + pageHistory as pageHistoryTable, + pages as pagesTable, + tree as treeTable, + userGroups as userGroupsTable, + users as usersTable +} from '../db/schema.ts' +import { CustomError, dataPathRoot } from '../helpers/common.ts' +import { GLOBAL_PERMISSIONS, PAGE_PERMISSIONS } from './groups.ts' +import type { GroupRule, GroupRuleMatch } from './groups.ts' +import type { NavigationItem } from './navigation.ts' + +/** + * Import model — reading a `.wkbackup` package into this instance. + * + * `dev/specs/wkbackup.md` is the format, and §12 of it is the contract for every record shape read + * here: the exporter that writes these packages lives in the 2.x codebase (`server/core/backup.js`), + * so nothing type-checks one against the other. + * + * **A record is a 2.x database row.** The exporter selects the table and writes it out, so the field + * names are 2.x's own column names and the ids are 2.x's own integers — `localeCode` rather than + * `locale`, `editorKey` rather than `editor`, `filename` rather than `fileName`, and an `authorId` + * that is a number meaning nothing here. §12 is written from that exporter rather than around it. + * + * **The package never reaches this server as a file.** The browser holds it, walks it, and drives + * these routes a batch at a time — which is why a session is a row rather than a variable: in an HA + * set the next batch is answered by a different instance, and a tab that closed has to be able to say + * where it got to. + * + * **Nothing here is a second write path into the database.** Every handler is a translation onto the + * model that already owns the records it writes — `pages.adoptStoredPage`, `assets.adoptStoredFile`, + * `tree.getFolder`, `navigation.siteNavId` — which is what makes an imported page get its history + * entry, its storage-target copies and its queued render without any of that being reimplemented. The + * two exceptions are `pageHistory` and `comments`, which have no adopt-shaped entry point because + * nothing but an import ever creates one from the outside; both go through drizzle directly, and both + * are keyed by a derived id so that replaying a batch upserts rather than duplicates. + */ + +/** + * The root every import namespace is derived under. + * + * A fixed constant rather than anything generated: the whole point is that the same package imported + * into the same site derives the same record ids next week as it did today, on this instance and on + * a rebuilt one. + */ +const IMPORT_NAMESPACE_ROOT = '4e2f8f3a-6c1d-4a5e-9b70-2c4f6d8e1a93' + +/** The container version this reader understands. Anything above is refused, naming both numbers. */ +const SUPPORTED_FORMAT_VERSION = 1 + +/** The only `manifest.source.kind` implemented. A `wikijs3` restore is a separate feature. */ +const SUPPORTED_SOURCE = 'wikijs2' + +/** + * What the operator may tick, as the overlay names them. + * + * `settings` is deliberately absent — see `dev/specs/wkbackup.md` §11. A stream whose content kind is + * not in the session's `includes` is refused rather than ignored, so a browser that batches something + * nobody asked for is told, instead of quietly writing it. + */ +export const IMPORT_CONTENT_KINDS = [ + 'assets', + 'pages', + 'comments', + 'history', + 'groups', + 'users', + 'navigation' +] as const +export type ImportContentKind = (typeof IMPORT_CONTENT_KINDS)[number] + +/** Which tick each stream belongs to. `locales` and `tree` ride along with the content that needs them. */ +const STREAM_REQUIRES: Record = { + locales: null, + groups: 'groups', + users: 'users', + tree: null, + pages: 'pages', + 'page-history': 'history', + assets: 'assets', + comments: 'comments', + navigation: 'navigation' +} + +/** Streams that belong to the wiki rather than to any one site. */ +const INSTANCE_STREAMS = new Set(['locales', 'groups', 'users']) + +/** Streams that belong to a site, and are posted with the target site's id. */ +const SITE_STREAMS = new Set(['tree', 'pages', 'page-history', 'assets', 'comments', 'navigation']) + +/** Records per batch. The browser's figure too — `dev/specs/wkbackup.md` §7. */ +export const MAX_BATCH_RECORDS = 500 + +/** + * How large a batch of records may be, as a request body. + * + * Above the instance's `bodyParserLimit` (5 MB by default) and declared per route, because that + * setting is about what a user may POST to an ordinary API and this is an administrator streaming a + * wiki through in pieces. It has to clear a batch of records that each carry a whole page: a page's + * source only spills to a blob above 1 MiB (`dev/specs/wkbackup.md` §2), so one record alone can be a + * couple of megabytes once JSON has escaped it, and a batch of 500 short ones is not the shape that + * matters here. + * + * The browser aims well under this rather than at it, and halves a batch that comes back 413 — see + * `helpers/wkbackupImport.js`. So this is the ceiling that makes a legitimate batch fit, not a figure + * anything is tuned to. + */ +export const MAX_BATCH_BYTES = 16 * 1024 * 1024 + +/** A blob bigger than this is refused rather than buffered. 2 GiB is `Buffer`'s own ceiling anyway. */ +export const MAX_BLOB_BYTES = 2 * 1024 * 1024 * 1024 + +/** + * How long a session's staging directory survives if `finish` is never called. + * + * The tab that was driving it closed, and nothing else will ever come back for those bytes. Swept by + * the same daily task that purges the audit log rather than on a timer of its own. + */ +const SESSION_MAX_AGE_HOURS = 48 + +/** + * 2.x editor keys → 3.x ones. + * + * Only `markdown`, `asciidoc` and `redirect` mean the same thing in both. The other two are 2.x's + * HTML editors, and they land on `markdown` rather than on 3.x's `visual`: markdown is configured + * with `allowHTML`, so a body of HTML renders as it stood, where `visual` is a WYSIWYG over markdown + * and would be handed a document it does not parse. Every page that takes this road is counted and + * named in the log — it reads correctly, but nobody chose markdown for it. + */ +const EDITOR_MAP: Record = { + markdown: 'markdown', + asciidoc: 'asciidoc', + redirect: 'redirect', + ckeditor: 'markdown', + code: 'markdown' +} + +/** 2.x page-rule match kinds that 3.x also has. `SUBTREE` and `TAGALL` are 3.x additions. */ +const RULE_MATCHES = new Set(['START', 'END', 'REGEX', 'TAG', 'EXACT']) + +/** 2.x navigation item kinds → 3.x ones. */ +const NAV_KINDS: Record = { + link: 'link', + header: 'header', + divider: 'separator' +} + +/** + * The two 2.x groups that exist here already, by their fixed 2.x ids. + * + * 3.x seeds an Administrators and a Guests group of its own, with rules that are not 2.x's to + * overwrite — a guests group that came back granting what somebody's old wiki granted is not a + * migration, it is a change of who can read the new one. So these two are mapped rather than created + * and only their MEMBERSHIPS carry. See `dev/specs/wkbackup.md` §11. + */ +const SYSTEM_GROUP_SOURCE_IDS = { administrators: 1, guests: 2 } + +/** + * Editors whose pages have no body to render. + * + * A redirection is a destination and a place in the tree, and a blog is an index of other pages; + * neither has a stored render and neither ever gets one, so telling a reader that one is "waiting to + * be rendered" would be waiting for something that is never coming — and counting them at the end + * would report a migration as half-finished for ever. + */ +const BODYLESS_EDITORS = new Set(['redirect', 'blog']) + +/** What a page's `render` says until the render queue gets to it — `dev/specs/wkbackup.md` §8. */ +const PENDING_RENDER_HTML = + '

This page has been imported and is waiting to be rendered.

' + +export interface ImportSessionSite { + /** `manifest.sites[].id` — the package's own name for the site, `default` for a 2.x package. */ + sourceId: string + /** The site on THIS instance it lands in. */ + siteId: string +} + +export interface ImportSession { + id: string + namespace: string + source: string + sites: ImportSessionSite[] + includes: ImportContentKind[] + overwrite: boolean + state: 'open' | 'finished' | 'failed' + progress: Record + warnings: string[] + actorId: string | null + createdAt: Date + updatedAt: Date +} + +/** What one batch did. `warnings` is what the operator reads in the log. */ +export interface IngestResult { + imported: number + skipped: number + warnings: string[] +} + +/** What the browser checked before it created a session, as `preflight` answers it. */ +export interface PreflightResult { + ok: boolean + errors: string[] + warnings: string[] +} + +function nowish() { + return sql`now()` +} + +/** Trim, lowercase, and treat anything that is not a string as absent. */ +function emailOf(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : '' +} + +function stringOf(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +/** + * A date off a record, or undefined. + * + * Undefined rather than `new Date()`: a column that defaults to now is the honest answer for a record + * that did not say, and stamping today's date on a page written in 2019 is exactly what + * `adoptStoredPage` takes `createdAt` to avoid. + */ +function dateOf(value: unknown): Date | undefined { + if (typeof value !== 'string' && typeof value !== 'number') { + return undefined + } + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? undefined : parsed +} + +/** + * Why a record was passed over, in terms somebody can act on. + * + * "A page record had no path or no locale" is true and useless: it does not say WHICH of the two, and + * repeated once per record it buries everything else in the log. What is nearly always wrong is that + * the package spells a field differently from what this reader looks for — so the message names the + * fields that were missing and then lists the keys the record actually carried, which turns a + * thousand identical lines into the answer. + * + * Identical messages are collapsed with a count before they leave `ingest`, so naming the keys costs + * one line per batch rather than one per record. + */ +function describeSkip(what: string, missing: string[], record: any): string { + const keys = record && typeof record === 'object' ? Object.keys(record) : [] + const carried = keys.length > 0 ? keys.slice(0, 24).join(', ') : '(nothing)' + const article = /^[aeiou]/i.test(what) ? 'An' : 'A' + return `${article} ${what} record was skipped: no ${missing.join(' and no ')}. The record carried: ${carried}.` +} + +/** + * One line for a whole batch of records that had nowhere to go, naming a few of them. + * + * The alternative is a line per record, which is what a truncated pages stream turns into: a hundred + * and forty lines of "the history of X was skipped" that bury the one line above them saying the + * pages stream was short. The count is the useful part and the examples are enough to recognise it. + */ +function describeOrphans(what: string, paths: string[]): string { + const distinct = [...new Set(paths)] + const examples = distinct.slice(0, 5).join(', ') + const rest = distinct.length > 5 ? `, and ${distinct.length - 5} more` : '' + return `${paths.length} ${what} skipped: no page in this wiki to attach them to (${examples}${rest}). Either the pages were not imported, or they were deleted in the source wiki, which keeps their history.` +} + +/** The fields of `required` that this record does not have, for `describeSkip`. */ +function missingOf(present: Record): string[] { + return Object.entries(present) + .filter(([, value]) => !value) + .map(([name]) => name) +} + +/** + * A 2.x icon name in Iconify's spelling. + * + * 2.x wrote webfont class names — `mdi-home`, `las la-cog` — and 3.x dropped both fonts, so a name + * stored as it stood would resolve to nothing. The frontend maps the legacy spellings for data + * written before the fonts went, but there is no reason to write new ones: this is the only moment + * the value is in hand, so it is rewritten here. See CLAUDE.md, Icons. + */ +function normalizeIcon(value: unknown): string | undefined { + const raw = stringOf(value).trim() + if (!raw) { + return undefined + } + if (raw.includes(':')) { + return raw + } + const la = raw.match(/^la[srdb]?\s+la-(.+)$/) + if (la) { + return `la:${la[1]}` + } + const mdi = raw.match(/^mdi-(.+)$/) + if (mdi) { + return `mdi:${mdi[1]}` + } + return undefined +} + +class Import { + /** + * Check a package before anything is written. + * + * Everything answerable from `manifest.json` alone, which the browser has read by the time it calls + * this — the container version, the source kind, that every stream the manifest promises has a path + * and a schema this reader knows, and that the site the operator picked exists here. Cheap on + * purpose: the point is to fail an eight-gigabyte file in the first second rather than in the + * fortieth minute. + * + * Errors stop the import; warnings are things it will carry on past and the operator should know + * about. + */ + async preflight(manifest: any, targetSiteId: string): Promise { + const errors: string[] = [] + const warnings: string[] = [] + + if (!manifest || typeof manifest !== 'object') { + return { ok: false, errors: ['The package has no readable manifest.'], warnings } + } + if (manifest.format !== 'wkbackup') { + errors.push('This file is not a Wiki.js backup package.') + } + const formatVersion = Number(manifest.formatVersion) + if (!Number.isInteger(formatVersion) || formatVersion < 1) { + errors.push('The package does not say which format version it is.') + } else if (formatVersion > SUPPORTED_FORMAT_VERSION) { + errors.push( + `The package is format version ${formatVersion}; this wiki reads up to ${SUPPORTED_FORMAT_VERSION}. It was made by a newer version of Wiki.js.` + ) + } + if (manifest.source?.kind !== SUPPORTED_SOURCE) { + errors.push( + `This is a ${stringOf(manifest.source?.kind, 'unknown')} package. Import from Wiki.js 2.x reads ${SUPPORTED_SOURCE} packages.` + ) + } + + const sites = Array.isArray(manifest.sites) ? manifest.sites : [] + if (sites.length < 1) { + errors.push('The manifest lists no sites, so there is nothing to import.') + } else if (sites.length > 1) { + // -> Not an error: the first site is imported and the rest are named, which is more useful than + // refusing a package a future multi-site exporter produced + warnings.push( + `The package holds ${sites.length} sites. Only the first (${stringOf(sites[0]?.title, sites[0]?.id)}) is imported.` + ) + } + + const site = await WIKI.models.sites.getSiteById({ id: targetSiteId }) + if (!site) { + errors.push('The site to import into does not exist.') + } + + /* + Every stream the manifest promises, instance-wide and site-scoped alike, held to the rules in + `dev/specs/wkbackup.md` §3: a schema above what is known refuses that stream, a name that is not + recognised is skipped with a warning and is never fatal. + */ + const streamSets: Array<[string, any]> = [ + ['', manifest.streams ?? {}], + [stringOf(sites[0]?.id, 'default'), sites[0]?.streams ?? {}] + ] + for (const [scope, streams] of streamSets) { + for (const [name, declared] of Object.entries(streams ?? {})) { + const known = scope ? SITE_STREAMS.has(name) : INSTANCE_STREAMS.has(name) + if (!known) { + // -> `settings` is expected and is reported by the importer rather than as a surprise here + if (name !== 'settings') { + warnings.push(`The package carries a "${name}" stream this wiki has no reader for.`) + } + continue + } + if (!declared?.path) { + errors.push(`The "${name}" stream does not say where it is in the package.`) + } + const schema = Number(declared?.schema) + if (!Number.isInteger(schema) || schema < 1) { + errors.push(`The "${name}" stream does not say which schema it is.`) + } else if (schema > 1) { + errors.push( + `The "${name}" stream is schema ${schema}; this wiki reads up to 1. It was made by a newer version of Wiki.js.` + ) + } + } + } + + for (const warning of Array.isArray(manifest.warnings) ? manifest.warnings : []) { + warnings.push(`The export reported: ${stringOf(warning)}`) + } + + return { ok: errors.length < 1, errors, warnings } + } + + /** Open a session. Everything the batches need to agree about is settled here and nowhere else. */ + async createSession({ + source, + sourceInstanceId, + sites, + includes, + overwrite, + actorId + }: { + source: string + sourceInstanceId?: string + sites: ImportSessionSite[] + includes: string[] + overwrite: boolean + actorId: string | null + }): Promise { + if (source !== SUPPORTED_SOURCE) { + throw new CustomError( + 'importUnsupportedSource', + `Only ${SUPPORTED_SOURCE} packages can be imported.`, + 400 + ) + } + if (sites.length < 1) { + throw new CustomError('importNoSites', 'No target site was given for the import.', 400) + } + for (const entry of sites) { + const site = await WIKI.models.sites.getSiteById({ id: entry.siteId }) + if (!site) { + throw new CustomError('importInvalidSite', 'The site to import into does not exist.', 400) + } + } + const kinds = includes.filter((kind): kind is ImportContentKind => + (IMPORT_CONTENT_KINDS as readonly string[]).includes(kind) + ) + if (kinds.length < 1) { + throw new CustomError('importNothingSelected', 'Nothing was selected to import.', 400) + } + + /* + What the derived ids of this import hang off, and it is deliberately not random. + + An import that fell over — a dropped connection, a tab closed, a package that turned out to be + missing a blob — is re-run from the top, because there is no resume button and replaying is + supposed to be free. It is only free if the second run derives the ids the first one did, and + that means the namespace has to be a function of what is being imported and where, rather than + of when somebody pressed the button. Pages, users, groups and assets are matched on their + natural keys and never cared; comments and page history have no natural key, so without this + the second run is a second copy of every one of them. + + The source instance is in the key so that two different 2.x wikis imported into the same site do + not have their comment #5 derive the same id, and the target site is in it so that importing the + same package into two sites puts two separate copies side by side rather than one shared row. + */ + const namespace = uuidv5( + [ + source, + sourceInstanceId ?? '', + ...sites.map((entry) => `${entry.sourceId}>${entry.siteId}`).sort() + ].join('|'), + IMPORT_NAMESPACE_ROOT + ) + + const rows = await WIKI.db + .insert(importSessionsTable) + .values({ source, namespace, sites, includes: kinds, overwrite, actorId }) + .returning() + return rows[0] as unknown as ImportSession + } + + async getSession(id: string): Promise { + const rows = await WIKI.db + .select() + .from(importSessionsTable) + .where(eq(importSessionsTable.id, id)) + .limit(1) + return (rows[0] as unknown as ImportSession) ?? null + } + + /** The session, or a 404 — every route below starts here. */ + async requireOpenSession(id: string): Promise { + const session = await this.getSession(id) + if (!session) { + throw new CustomError('importNoSession', 'No such import session.', 404) + } + if (session.state !== 'open') { + throw new CustomError('importSessionClosed', 'This import session is already finished.', 409) + } + return session + } + + /** Where a session's uploaded blobs wait for the metadata that references them. */ + stagingDir(sessionId: string): string { + return path.join(dataPathRoot(), 'cache', 'import', sessionId) + } + + /** + * Take one blob. + * + * The name IS the checksum, so it is verified rather than trusted: that is the whole of what + * content addressing establishes, and a reader that skipped the check would be taking the + * uploader's word for the one thing the scheme exists to prove. + */ + async putBlob(sessionId: string, digest: string, data: Buffer): Promise<{ bytes: number }> { + if (!/^[0-9a-f]{64}$/.test(digest)) { + throw new CustomError('importBadBlobName', 'A blob is named by its SHA-256 digest.', 400) + } + if (data.length > MAX_BLOB_BYTES) { + throw new CustomError('importBlobTooLarge', 'That file is too large to import.', 413) + } + const actual = crypto.createHash('sha256').update(data).digest('hex') + if (actual !== digest) { + throw new CustomError( + 'importBlobChecksum', + 'The uploaded file does not match the digest it was sent under.', + 400 + ) + } + const dir = this.stagingDir(sessionId) + await fs.mkdir(dir, { recursive: true }) + // -> Written beside and renamed, so a request that died halfway leaves nothing a later batch + // would read as a complete file + const target = path.join(dir, digest) + const temp = `${target}.part` + await fs.writeFile(temp, data) + await fs.rename(temp, target) + return { bytes: data.length } + } + + /** The staged bytes of a blob, or null when nothing uploaded it. */ + async #readBlob(sessionId: string, digest: string): Promise { + if (!/^[0-9a-f]{64}$/.test(digest)) { + return null + } + try { + return await fs.readFile(path.join(this.stagingDir(sessionId), digest)) + } catch { + return null + } + } + + /** + * A stable id for a record that has no natural key here. + * + * `uuidv5` over the session's own namespace, so the same record derives the same id on a replay — + * which is what makes a batch safe to retry — while two sessions importing two different packages + * cannot collide. See `dev/specs/wkbackup.md` §4. + */ + #derive(session: ImportSession, site: string, entity: string, sourceId: unknown): string { + return uuidv5(`${site}:${entity}:${String(sourceId)}`, session.namespace) + } + + /** + * Record what a source row became here. + * + * The package speaks 2.x's integer ids and this wiki matches on natural keys — a user by email, a + * page by path — so the two have to be joined up for the records that reference each other by id: a + * page's author, a comment's page, a user's groups. Written as the owning stream runs, read by the + * streams that come after it. + */ + async #remember( + sessionId: string, + entity: string, + pairs: Array<{ sourceId: unknown; targetId: string }> + ): Promise { + const rows = pairs + .filter((pair) => pair.sourceId !== undefined && pair.sourceId !== null) + .map((pair) => ({ + sessionId, + entity, + sourceId: String(pair.sourceId), + targetId: pair.targetId + })) + if (rows.length < 1) { + return + } + await WIKI.db + .insert(importIdMapTable) + .values(rows) + .onConflictDoUpdate({ + target: [importIdMapTable.sessionId, importIdMapTable.entity, importIdMapTable.sourceId], + set: { targetId: sql`excluded."targetId"` } + }) + } + + /** + * Look several source ids up at once. + * + * Per batch rather than per record: a batch of 500 comments names at most 500 pages and usually far + * fewer, and one indexed read answers all of them. + */ + async #resolveIds( + sessionId: string, + entity: string, + sourceIds: unknown[] + ): Promise> { + const keys = [...new Set(sourceIds.filter((id) => id !== undefined && id !== null).map(String))] + if (keys.length < 1) { + return new Map() + } + const rows = await WIKI.db + .select({ sourceId: importIdMapTable.sourceId, targetId: importIdMapTable.targetId }) + .from(importIdMapTable) + .where( + and( + eq(importIdMapTable.sessionId, sessionId), + eq(importIdMapTable.entity, entity), + inArray(importIdMapTable.sourceId, keys) + ) + ) + return new Map(rows.map((row) => [row.sourceId, row.targetId])) + } + + /** The locale a site files content under when the package does not say — 2.x assets have none. */ + #primaryLocaleOf(siteId: string): string { + return WIKI.sites?.[siteId]?.config?.locales?.primary ?? 'en' + } + + /** Merge a patch into the session row. Read back, so the caller sees what it wrote. */ + async #patch(id: string, patch: Record): Promise { + await WIKI.db + .update(importSessionsTable) + .set({ ...patch, updatedAt: nowish() }) + .where(eq(importSessionsTable.id, id)) + } + + /** + * Collapse identical warnings into one line carrying a count. + * + * A batch is up to 500 records and they nearly always go wrong the same way — a field the package + * spells differently, a page none of them can find. Repeating the line once per record buries every + * other warning in the log and tells the reader nothing the first one did not. + */ + #collapse(warnings: string[]): string[] { + const counts = new Map() + for (const warning of warnings) { + counts.set(warning, (counts.get(warning) ?? 0) + 1) + } + return [...counts].map(([warning, count]) => (count > 1 ? `${warning} (x${count})` : warning)) + } + + /** Count a batch against the session, and keep whatever it could not carry. */ + async #record( + session: ImportSession, + stream: string, + result: IngestResult + ): Promise { + result = { ...result, warnings: this.#collapse(result.warnings) } + const progress = { + ...session.progress, + [stream]: (session.progress[stream] ?? 0) + result.imported + } + /* + Warnings are capped. A package whose every one of ninety thousand pages is on a dead editor + would otherwise put ninety thousand strings in a jsonb column and in the log panel's DOM; the + count still tells the truth, and the first two hundred say what the problem is. + */ + const warnings = [...session.warnings, ...result.warnings].slice(0, 200) + await this.#patch(session.id, { progress, warnings }) + return result + } + + /** + * Write one batch of records. + * + * The dispatch is flat on purpose: each handler knows only its own stream, so streams can be run in + * any order that satisfies the references between them, and a failed batch can be retried on its + * own. What order that is, is the browser's business — `dev/specs/wkbackup.md` §11 has it. + */ + async ingest({ + sessionId, + stream, + siteId, + records + }: { + sessionId: string + stream: string + siteId?: string + records: any[] + }): Promise { + const session = await this.requireOpenSession(sessionId) + + if (!(stream in STREAM_REQUIRES)) { + throw new CustomError('importUnknownStream', `This wiki has no reader for "${stream}".`, 400) + } + const requires = STREAM_REQUIRES[stream] + if (requires && !session.includes.includes(requires)) { + throw new CustomError( + 'importStreamNotSelected', + `"${requires}" was not selected for this import.`, + 400 + ) + } + if (records.length > MAX_BATCH_RECORDS) { + throw new CustomError( + 'importBatchTooLarge', + `At most ${MAX_BATCH_RECORDS} records per request.`, + 400 + ) + } + + if (SITE_STREAMS.has(stream)) { + const target = session.sites.find((entry) => entry.siteId === siteId) + if (!target) { + throw new CustomError( + 'importUnmappedSite', + 'That site is not one this import session was opened for.', + 400 + ) + } + return this.#record( + session, + stream, + await this.#ingestSiteStream(session, stream, target, records) + ) + } + return this.#record(session, stream, await this.#ingestInstanceStream(session, stream, records)) + } + + async #ingestInstanceStream( + session: ImportSession, + stream: string, + records: any[] + ): Promise { + switch (stream) { + case 'locales': + return this.#localesStream(records) + case 'groups': + return this.#groupsStream(session, records) + case 'users': + return this.#usersStream(session, records) + default: + throw new CustomError( + 'importUnknownStream', + `This wiki has no reader for "${stream}".`, + 400 + ) + } + } + + async #ingestSiteStream( + session: ImportSession, + stream: string, + target: ImportSessionSite, + records: any[] + ): Promise { + switch (stream) { + case 'tree': + return this.#treeStream(target, records) + case 'pages': + return this.#pagesStream(session, target, records) + case 'page-history': + return this.#pageHistoryStream(session, target, records) + case 'assets': + return this.#assetsStream(session, target, records) + case 'comments': + return this.#commentsStream(session, target, records) + case 'navigation': + return this.#navigationStream(session, target, records) + default: + throw new CustomError( + 'importUnknownStream', + `This wiki has no reader for "${stream}".`, + 400 + ) + } + } + + // ============================================================================================== + // INSTANCE-WIDE STREAMS + // ============================================================================================== + + /** + * Locales — reported, never installed. + * + * Installing one is a download from a third party, and doing it unasked in the middle of somebody's + * migration is not this feature's business. What matters is that the operator learns their pages + * are arriving in a locale this wiki has no strings for, while there is still time to add it. + */ + async #localesStream(records: any[]): Promise { + const installed = new Set( + (await WIKI.models.locales.getInstalledLocales()).map((locale: any) => locale.code) + ) + const warnings: string[] = [] + let imported = 0 + for (const record of records) { + const code = stringOf(record?.code).trim() + if (!code) { + continue + } + if (installed.has(code)) { + imported++ + } else { + warnings.push( + `The package uses the locale "${code}", which is not installed here. Its content is imported; install the locale under Administration → Locale.` + ) + } + } + return { imported, skipped: records.length - imported, warnings } + } + + /** + * Groups. + * + * Matched by name, because that is what a group IS to whoever runs the wiki — a 2.x row id means + * nothing here, and an administrator who has already made an "Editors" group wants the import to + * land in it rather than beside it. + * + * Every group is remembered whether it was created, adopted or mapped onto one of 3.x's own, since + * the users and navigation streams resolve their references by 2.x group id and cannot tell the + * three apart. + */ + async #groupsStream(session: ImportSession, records: any[]): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + const mapped: Array<{ sourceId: unknown; targetId: string }> = [] + const systemGroupIds = await WIKI.models.groups.systemGroupIds() + const targetSiteIds = session.sites.map((entry) => entry.siteId) + + for (const record of records) { + const sourceId = record?.id + const name = stringOf(record?.name).trim() + if (sourceId === undefined || sourceId === null || !name) { + warnings.push('A group record had no id or no name and was skipped.') + skipped++ + continue + } + + // -> The two 3.x already has. Mapped rather than written; see SYSTEM_GROUP_SOURCE_IDS + if (record?.isSystem === true) { + if (Number(sourceId) === SYSTEM_GROUP_SOURCE_IDS.administrators && systemGroupIds[0]) { + mapped.push({ sourceId, targetId: systemGroupIds[0] }) + } else if (Number(sourceId) === SYSTEM_GROUP_SOURCE_IDS.guests) { + mapped.push({ sourceId, targetId: WIKI.data.systemIds.guestsGroupId }) + } else { + warnings.push(`The system group "${name}" has no equivalent here and was skipped.`) + } + skipped++ + continue + } + + const { permissions, dropped: droppedPermissions } = this.#translatePermissions( + record?.permissions + ) + const { + rules, + dropped: droppedRoles, + droppedRules + } = this.#translateRules(record?.pageRules, targetSiteIds) + for (const permission of new Set([...droppedPermissions, ...droppedRoles])) { + warnings.push( + `"${permission}" has no meaning in Wiki.js 3.x and was dropped from group "${name}".` + ) + } + for (const kind of new Set(droppedRules)) { + warnings.push( + `A rule on "${name}" matched pages by "${kind}", which Wiki.js 3.x has no equivalent for, and was dropped.` + ) + } + + const existing = await WIKI.db + .select({ id: groupsTable.id, isSystem: groupsTable.isSystem }) + .from(groupsTable) + .where(eq(groupsTable.name, name)) + .limit(1) + + if (existing[0]) { + mapped.push({ sourceId, targetId: existing[0].id }) + if (existing[0].isSystem) { + warnings.push(`"${name}" is a system group here and was not changed.`) + skipped++ + } else if (session.overwrite) { + await WIKI.db + .update(groupsTable) + .set({ permissions, rules, updatedAt: nowish() }) + .where(eq(groupsTable.id, existing[0].id)) + imported++ + } else { + warnings.push(`A group named "${name}" already exists. Its members are still imported.`) + skipped++ + } + continue + } + + const id = this.#derive(session, '', 'group', sourceId) + await WIKI.db + .insert(groupsTable) + .values({ + id, + name, + permissions, + rules, + redirectOnLogin: stringOf(record?.redirectOnLogin), + isSystem: false + }) + .onConflictDoUpdate({ target: groupsTable.id, set: { name, permissions, rules } }) + mapped.push({ sourceId, targetId: id }) + imported++ + } + + await this.#remember(session.id, 'group', mapped) + // -> The rules cache is what `checkAccess` reads, so it has to be told once per batch rather than + // once per group + await WIKI.models.groups.reloadCache() + return { imported, skipped, warnings } + } + + /** + * 2.x global permissions, filtered to the ones 3.x has. Never translated — see the spec, §6. + * + * 2.x kept one list where 3.x keeps two, so a group's `permissions` carries page permissions + * alongside global ones. Those are dropped from this list in silence rather than reported: they are + * not meaningless names, they are names that live in `rules` here, and the same record's + * `pageRules` is where they arrive. `manage:navigation` is the one that most obviously moved. + * + * What IS reported is a name in neither vocabulary — `manage:api`, which 3.x has no equivalent for + * at all, and anything a 2.x installation invented. + */ + #translatePermissions(value: unknown): { permissions: string[]; dropped: string[] } { + const global = new Set(GLOBAL_PERMISSIONS as readonly string[]) + const page = new Set(PAGE_PERMISSIONS) + const permissions: string[] = [] + const dropped: string[] = [] + for (const entry of Array.isArray(value) ? value : []) { + const name = stringOf(entry) + if (global.has(name)) { + permissions.push(name) + } else if (name && !page.has(name)) { + dropped.push(name) + } + } + return { permissions: [...new Set(permissions)], dropped: [...new Set(dropped)] } + } + + /** + * 2.x page rules → 3.x ones. + * + * Every rule is scoped to the sites being imported into. A 2.x wiki had exactly one site, so its + * rules were written with no notion of scope at all; carried across unscoped they would speak for + * every site on this instance, which is a rule nobody wrote. + */ + #translateRules( + value: unknown, + siteIds: string[] + ): { rules: GroupRule[]; dropped: string[]; droppedRules: string[] } { + const known = new Set(PAGE_PERMISSIONS) + const rules: GroupRule[] = [] + const dropped: string[] = [] + const droppedRules: string[] = [] + for (const entry of Array.isArray(value) ? value : []) { + const match = stringOf(entry?.match).toUpperCase() as GroupRuleMatch + if (!RULE_MATCHES.has(match)) { + droppedRules.push(stringOf(entry?.match, '(none)')) + continue + } + const roles: string[] = [] + for (const role of Array.isArray(entry?.roles) ? entry.roles : []) { + const name = stringOf(role) + if (known.has(name)) { + roles.push(name) + } else if (name) { + dropped.push(name) + } + } + if (roles.length < 1) { + continue + } + rules.push({ + id: uuidv5(`rule:${stringOf(entry?.id)}`, WIKI.data.systemIds.localAuthId), + name: stringOf(entry?.name, 'Imported Rule'), + roles: [...new Set(roles)], + match, + // -> 2.x has no FORCEALLOW; `deny` is the whole of what it could say + mode: entry?.deny === true ? 'DENY' : 'ALLOW', + path: stringOf(entry?.path), + tags: (Array.isArray(entry?.tags) ? entry.tags : []).map((tag: unknown) => stringOf(tag)), + locales: (Array.isArray(entry?.locales) ? entry.locales : []).map((code: unknown) => + stringOf(code) + ), + sites: [...siteIds] + }) + } + return { rules, dropped: [...new Set(dropped)], droppedRules } + } + + /** + * Users, matched on the email address and nothing else. + * + * Not the display name, which is not unique and which people change; not the 2.x row id, which + * means nothing here. An address that already has an account IS that person, so an import fills in + * what that account is missing rather than making a second one beside it. + * + * **The account running the import is never written to**, whatever `overwrite` says. It is in + * practice the root administrator, it is the session the rest of the import is authenticated by, + * and the package's copy of that address may carry a different password hash, a different 2FA + * secret or `isActive: false`. Overwriting it is how an operator locks themselves out of the + * instance they are migrating into, thousands of records from the end. + */ + async #usersStream(session: ImportSession, records: any[]): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + const actorEmail = session.actorId + ? emailOf((await WIKI.models.users.getById(session.actorId))?.email) + : '' + const localAuthId = WIKI.data.systemIds.localAuthId + + for (const record of records) { + const email = emailOf(record?.email) + if (!email) { + warnings.push('A user record had no email address and was skipped.') + skipped++ + continue + } + if (record?.isSystem === true) { + // -> 3.x seeds its own guest account; 2.x's is not a person and has nothing to carry + skipped++ + continue + } + if (email === actorEmail) { + warnings.push( + `"${email}" is the account running this import and was left exactly as it is, including its password and 2FA.` + ) + /* + Left untouched, but still remembered. This account is usually the old wiki's administrator + and therefore the author of a good deal of it; without the mapping every page they wrote + would fall through to "whoever ran the import", which is only the same person by luck. + */ + if (session.actorId) { + await this.#remember(session.id, 'user', [ + { sourceId: record?.id, targetId: session.actorId } + ]) + } + skipped++ + continue + } + + const groupIds = await this.#resolveGroups(session, record?.groups, warnings, email) + const isLocal = stringOf(record?.providerKey, 'local') === 'local' + if (!isLocal) { + warnings.push( + `"${email}" signed in through "${stringOf(record?.providerKey)}" in 2.x. The account is imported without a password; configure that strategy under Administration → Authentication.` + ) + } + + /* + The password hash and the TOTP secret carry across verbatim — same bcryptjs at cost 12, same + RFC 6238 secret — so nobody resets a password or re-enrols an authenticator. See + `dev/specs/wkbackup.md` §10 item 2. Written here rather than through `users.createUser` + because that one hashes what it is given, and what is in hand is already a hash. + */ + const auth = isLocal + ? { + [localAuthId]: { + password: stringOf(record?.password), + mustChangePwd: record?.mustChangePwd === true, + restrictLogin: false, + tfaIsActive: record?.tfaIsActive === true, + tfaRequired: false, + tfaSecret: stringOf(record?.tfaSecret) + } + } + : {} + const meta = { + jobTitle: stringOf(record?.jobTitle), + location: stringOf(record?.location), + timezone: stringOf(record?.timezone) + } + const name = stringOf(record?.name, email) + + const existing = await WIKI.db + .select({ id: usersTable.id, isSystem: usersTable.isSystem }) + .from(usersTable) + .where(eq(usersTable.email, email)) + .limit(1) + + let userId: string + if (existing[0]) { + userId = existing[0].id + if (existing[0].isSystem) { + warnings.push(`"${email}" is a system account here and was not changed.`) + skipped++ + continue + } + if (!session.overwrite) { + warnings.push(`"${email}" already has an account here and was left alone.`) + // -> Still the account that 2.x user id means, so anything they authored is still theirs + await this.#remember(session.id, 'user', [{ sourceId: record?.id, targetId: userId }]) + skipped++ + continue + } + await WIKI.db + .update(usersTable) + .set({ + name, + auth, + meta, + prefs: { locale: stringOf(record?.localeCode) }, + isActive: record?.isActive !== false, + isVerified: record?.isVerified !== false, + updatedAt: nowish() + }) + .where(eq(usersTable.id, userId)) + } else { + userId = this.#derive(session, '', 'user', record?.id ?? email) + await WIKI.db + .insert(usersTable) + .values({ + id: userId, + email, + name, + auth, + meta, + prefs: { locale: stringOf(record?.localeCode) }, + isActive: record?.isActive !== false, + isVerified: record?.isVerified !== false, + isSystem: false, + createdAt: dateOf(record?.createdAt), + updatedAt: dateOf(record?.updatedAt) + }) + .onConflictDoNothing({ target: usersTable.id }) + } + + if (groupIds.length > 0) { + await WIKI.db + .insert(userGroupsTable) + .values(groupIds.map((groupId) => ({ userId, groupId }))) + .onConflictDoNothing() + } + // -> Remembered whether created or matched: the pages, history, assets and comments that follow + // name their author by the 2.x user id, and an account that was already here is still the + // answer to it + await this.#remember(session.id, 'user', [{ sourceId: record?.id, targetId: userId }]) + imported++ + } + + return { imported, skipped, warnings } + } + + /** A record's 2.x group ids, through the session's map. Unmapped ones are named once per user. */ + async #resolveGroups( + session: ImportSession, + value: unknown, + warnings: string[], + subject: string + ): Promise { + const sourceIds = Array.isArray(value) ? value : [] + const map = await this.#resolveIds(session.id, 'group', sourceIds) + const ids: string[] = [] + for (const entry of sourceIds) { + const mapped = map.get(String(entry)) + if (mapped) { + ids.push(mapped) + } else { + warnings.push( + `"${subject}" belonged to a 2.x group that was not imported; that membership was dropped.` + ) + } + } + return [...new Set(ids)] + } + + // ============================================================================================== + // SITE STREAMS + // ============================================================================================== + + /** + * Folders. + * + * Pages and assets create the folders they need on their way in, so this stream exists for the two + * things they cannot supply: the title somebody gave a folder, and a folder with nothing in it. + */ + async #treeStream(target: ImportSessionSite, records: any[]): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + for (const record of records) { + const folderPath = stringOf(record?.path) + .trim() + .replace(/^\/+|\/+$/g, '') + const locale = stringOf(record?.localeCode).trim() + if (!folderPath || !locale) { + warnings.push( + describeSkip('folder', missingOf({ path: folderPath, localeCode: locale }), record) + ) + skipped++ + continue + } + try { + const folder = await WIKI.models.tree.getFolder({ + path: folderPath, + locale, + siteId: target.siteId, + createIfMissing: true + }) + const title = stringOf(record?.title).trim() + if (title && title !== folder.title) { + await WIKI.db + .update(treeTable) + .set({ title, updatedAt: nowish() }) + .where(eq(treeTable.id, folder.id)) + } + imported++ + } catch (err: any) { + warnings.push(`The folder "${folderPath}" could not be created: ${err.message}`) + skipped++ + } + } + return { imported, skipped, warnings } + } + + /** + * Pages. + * + * Straight onto `pages.adoptStoredPage`, which is the method the disk and git targets import + * through: it makes the create-or-overwrite decision, writes a history entry when it overwrites, + * mirrors the page to every configured storage target, and renders it with NO script or style + * permission whoever ran the import — which is exactly the rule this feature needs, since a + * `.wkbackup` is a file somebody handed the administrator. + * + * No render comes with the page. A 2.x one would be 2.x's output and wrong here in ways nothing + * could later detect, so the page gets a placeholder and a place in the render queue instead. See + * `dev/specs/wkbackup.md` §8. + */ + async #pagesStream( + session: ImportSession, + target: ImportSessionSite, + records: any[] + ): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + for (const record of records) { + const pagePath = stringOf(record?.path) + .trim() + .replace(/^\/+|\/+$/g, '') + const locale = stringOf(record?.localeCode).trim() + if (!pagePath || !locale) { + warnings.push( + describeSkip('page', missingOf({ path: pagePath, localeCode: locale }), record) + ) + skipped++ + continue + } + + const content = await this.#contentOf(session, record) + if (content === null) { + warnings.push(`The content of "${pagePath}" is missing from the package.`) + skipped++ + continue + } + + const sourceEditor = stringOf(record?.editorKey, 'markdown') + const editor = EDITOR_MAP[sourceEditor] ?? 'markdown' + if (!EDITOR_MAP[sourceEditor]) { + warnings.push( + `"${pagePath}" was written with the 2.x "${sourceEditor}" editor, which 3.x does not have. It was imported as markdown.` + ) + } + + try { + const page = await WIKI.models.pages.adoptStoredPage({ + siteId: target.siteId, + locale, + path: pagePath, + title: stringOf(record?.title, pagePath), + description: stringOf(record?.description), + editor, + tags: (Array.isArray(record?.tags) ? record.tags : []).map((tag: unknown) => + stringOf(tag) + ), + isPublished: record?.isPublished !== false, + content: editor === 'redirect' ? this.#redirectContent(content) : content, + createdAt: dateOf(record?.createdAt), + updatedAt: dateOf(record?.updatedAt), + authorId: await this.#authorFor(session, record?.authorId), + overwrite: session.overwrite + }) + if (!page) { + /* + Already here and `overwrite` is off, so nothing was written — but the page IS what that + 2.x page id means, and the comments stream has nothing but that id to find it by. Without + this, running an import a second time lands every comment on a page that "was not + imported" and drops the lot. + */ + const existing = await this.#pageAt(target.siteId, locale, pagePath) + if (existing) { + await this.#remember(session.id, 'page', [ + { sourceId: record?.id, targetId: existing.id } + ]) + } + skipped++ + continue + } + await this.#markPendingRender(target.siteId, page.id, editor) + // -> The comments stream names its page by the 2.x page id and has nothing else to go on + await this.#remember(session.id, 'page', [{ sourceId: record?.id, targetId: page.id }]) + imported++ + } catch (err: any) { + warnings.push(`"${pagePath}" could not be imported: ${err.message}`) + skipped++ + } + } + return { imported, skipped, warnings } + } + + /** + * Say that a page is waiting to be rendered, in the column a reader is served from. + * + * `adoptStoredPage` stores an empty render and queues a real one, which is right for a folder + * import of a handful of files and wrong for twelve thousand pages at once: the queue is one + * headless browser doing one page at a time, so for most of a day most of the wiki would be blank + * with nothing saying why. Written only over an empty render, so the first real one replaces it and + * nothing here can overwrite a page that already has HTML. + */ + async #markPendingRender(siteId: string, pageId: string, editor: string): Promise { + if (BODYLESS_EDITORS.has(editor)) { + return + } + await WIKI.db + .update(pagesTable) + .set({ render: PENDING_RENDER_HTML }) + .where( + and( + eq(pagesTable.id, pageId), + eq(pagesTable.siteId, siteId), + sql`coalesce(${pagesTable.render}, '') = ''` + ) + ) + } + + /** A 2.x redirection's target, in the JSON a 3.x redirection page holds. */ + #redirectContent(content: string): string { + const target = content.trim() + return JSON.stringify({ + kind: /^https?:\/\//i.test(target) ? 'url' : 'page', + target, + showInterstitial: false + }) + } + + /** + * A record's body, inline or out of a staged blob. + * + * Null means the package promised a blob and no upload delivered it, which is a skipped record + * rather than a failed batch — one missing file should not cost a migration the other eleven + * thousand pages. + */ + async #contentOf(session: ImportSession, record: any): Promise { + const blob = stringOf(record?.contentBlob).trim() + if (blob) { + const data = await this.#readBlob(session.id, blob) + return data ? data.toString('utf8') : null + } + return stringOf(record?.content) + } + + /** + * Whose change this was, from the 2.x user id the record carries. + * + * A 2.x row names its author by a number that means nothing here, so this goes through the id map + * the users stream filled. Every page and every history row needs an author, and the account that + * wrote it may simply not have been imported — the operator may have left Users unticked, or the + * account may have been deleted from the old wiki years ago. Whoever is running the import is the + * honest answer then: they are who caused the row to exist here. + */ + async #authorFor(session: ImportSession, sourceUserId: unknown): Promise { + if (sourceUserId !== undefined && sourceUserId !== null) { + const map = await this.#resolveIds(session.id, 'user', [sourceUserId]) + const mapped = map.get(String(sourceUserId)) + if (mapped) { + return mapped + } + } + if (session.actorId) { + return session.actorId + } + const systemActor = await WIKI.models.users.getSystemActorId() + if (!systemActor) { + throw new CustomError( + 'importNoActor', + 'This import has no account to attribute content to.', + 500 + ) + } + return systemActor + } + + /** The page a site-scoped record refers to, by the path it names. */ + async #pageAt(siteId: string, locale: string, pagePath: string): Promise<{ id: string } | null> { + const rows = await WIKI.db + .select({ id: pagesTable.id }) + .from(pagesTable) + .where( + and( + eq(pagesTable.siteId, siteId), + eq(pagesTable.locale, locale), + eq(pagesTable.path, pagePath) + ) + ) + .limit(1) + return rows[0] ?? null + } + + /** + * Page history. + * + * Written through drizzle rather than through `pageHistory.record`, which exists to capture a change + * being made now and takes the page as it stands — there is nothing for it to diff an imported + * version against. The id is derived, so a replayed batch updates the rows it already wrote. + */ + async #pageHistoryStream( + session: ImportSession, + target: ImportSessionSite, + records: any[] + ): Promise { + const warnings: string[] = [] + const orphans: string[] = [] + let imported = 0 + let skipped = 0 + + /* + Resolved by the 2.x PAGE ID, not by the path on the record. + + A history row records the path the page had AT THE TIME — that is the whole point of the column, + here as much as in 2.x — so every version written before a page was renamed carries its old + path. Matching on that finds nothing and throws away the history of exactly the pages whose + history is most worth having: the ones that moved. `pageId` is the identity that survives a + rename, and the pages stream has already recorded what each one became. + + The path is still what gets STORED, since "where was this page when this version was written" + is what a history list shows and what finding a deleted page again depends on. + */ + const pageMap = await this.#resolveIds( + session.id, + 'page', + records.map((record) => record?.pageId) + ) + + for (const record of records) { + const pagePath = stringOf(record?.path) + .trim() + .replace(/^\/+|\/+$/g, '') + const locale = stringOf(record?.localeCode).trim() + if (!pagePath || !locale) { + warnings.push( + describeSkip('page history', missingOf({ path: pagePath, localeCode: locale }), record) + ) + skipped++ + continue + } + /* + The path is the fallback, for a package whose pages stream was not imported at all — history + on its own, over pages that are already here. It cannot help a page that has since moved, + which is what the id map is for. + */ + const pageId = + pageMap.get(String(record?.pageId)) ?? + (await this.#pageAt(target.siteId, locale, pagePath))?.id + if (!pageId) { + /* + Nothing in this wiki for it to hang off. Two ways that happens and both are the package's + truth rather than a failure here: the pages stream was not imported, or the page was deleted + in the source wiki and 2.x kept its history — which it does, deliberately, so that a deleted + page can be recovered. + */ + orphans.push(pagePath) + skipped++ + continue + } + const content = await this.#contentOf(session, record) + if (content === null) { + warnings.push(`A version of "${pagePath}" is missing its content in the package.`) + skipped++ + continue + } + + const id = this.#derive(session, target.sourceId, 'pageHistory', record?.id) + const values = { + id, + pageId, + action: stringOf(record?.action, 'updated').slice(0, 16), + locale, + path: pagePath, + title: stringOf(record?.title, pagePath), + content, + meta: { + description: stringOf(record?.description), + editor: EDITOR_MAP[stringOf(record?.editorKey, 'markdown')] ?? 'markdown', + publishState: record?.isPublished === false ? 'draft' : 'published', + tags: Array.isArray(record?.tags) ? record.tags : [] + }, + // -> 2.x stamps a version with `versionDate`, falling back to when the row was written + versionDate: dateOf(record?.versionDate) ?? dateOf(record?.createdAt) ?? new Date(), + authorId: await this.#authorFor(session, record?.authorId), + siteId: target.siteId + } + await WIKI.db + .insert(pageHistoryTable) + .values(values) + .onConflictDoUpdate({ target: pageHistoryTable.id, set: values }) + imported++ + } + if (orphans.length > 0) { + warnings.push(describeOrphans('page history records', orphans)) + } + return { imported, skipped, warnings } + } + + /** + * Assets, from the blobs staged ahead of them. + * + * `assets.adoptStoredFile` is the same entry point the disk target's import uses: it makes the + * create-or-replace decision, builds a thumbnail, files the entry in the tree and fans the bytes + * out to every storage target the site writes to. + */ + async #assetsStream( + session: ImportSession, + target: ImportSessionSite, + records: any[] + ): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + for (const record of records) { + const fileName = stringOf(record?.filename).trim() + const digest = stringOf(record?.blob).trim() + if (!fileName || !digest) { + warnings.push( + describeSkip('asset', missingOf({ filename: fileName, blob: digest }), record) + ) + skipped++ + continue + } + /* + 2.x files assets in a tree of their own with no locale in it at all, so there is nothing on + the record to read and nothing to guess: they go in the site's primary locale, which is where + a 3.x site with one locale keeps everything anyway. + */ + const locale = this.#primaryLocaleOf(target.siteId) + const data = await this.#readBlob(session.id, digest) + if (!data) { + warnings.push(`The file "${fileName}" is missing from the package.`) + skipped++ + continue + } + try { + const asset = await WIKI.models.assets.adoptStoredFile({ + siteId: target.siteId, + locale, + folderPath: stringOf(record?.folderPath) + .trim() + .replace(/^\/+|\/+$/g, ''), + fileName, + data, + authorId: await this.#authorFor(session, record?.authorId), + overwrite: session.overwrite, + /* + The bytes came out of a ZIP in somebody's browser and nothing on this instance has them, + which is the opposite of the case `adoptStoredFile` is named for. Without this the asset + gets a row and a thumbnail and no content at all — a file the manager lists and previews + and cannot open, and an image that is broken on every page that used it. + */ + dispatch: true + }) + if (!asset) { + skipped++ + continue + } + imported++ + } catch (err: any) { + warnings.push(`"${fileName}" could not be imported: ${err.message}`) + skipped++ + } + } + return { imported, skipped, warnings } + } + + /** + * Comments, for the built-in provider. + * + * Not through `comments.create`, which is the posting path: it applies the site's cooldown, runs the + * spam check and stamps the comment with now. None of that is right for a record that was written + * years ago on another wiki. The id is derived, so a replayed batch updates rather than duplicates. + * + * An author with no account here stays a guest comment under the name and address on the record, + * which is what it already was in 2.x for anybody who commented without signing in. + */ + async #commentsStream( + session: ImportSession, + target: ImportSessionSite, + records: any[] + ): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + /* + A 2.x comment names its page by the 2.x page id and carries no path, so the pages stream has to + have run and remembered what each one became. Resolved for the whole batch in one read rather + than per comment. + */ + const pageMap = await this.#resolveIds( + session.id, + 'page', + records.map((record) => record?.pageId) + ) + const authorMap = await this.#resolveIds( + session.id, + 'user', + records.map((record) => record?.authorId) + ) + + const orphans: string[] = [] + for (const record of records) { + const content = stringOf(record?.content).trim() + if (record?.pageId === undefined || record?.pageId === null || !content) { + warnings.push( + describeSkip('comment', missingOf({ pageId: record?.pageId, content }), record) + ) + skipped++ + continue + } + const pageId = pageMap.get(String(record.pageId)) + if (!pageId) { + orphans.push(String(record.pageId)) + skipped++ + continue + } + + // -> 2.x keeps the commenter's own name, email and address on the row, for guests and signed-in + // authors alike + const authorId = authorMap.get(String(record?.authorId)) ?? null + const email = emailOf(record?.email) + + const id = this.#derive(session, target.sourceId, 'comment', record?.id) + const values = { + id, + pageId, + parentId: null, + content, + authorId, + authorName: stringOf(record?.name, email || 'Anonymous'), + // -> Only a guest's address is kept. A signed-in author's is on their account, and this column + // is never served + authorEmail: authorId ? '' : email, + authorIP: stringOf(record?.ip), + createdAt: dateOf(record?.createdAt) ?? new Date(), + updatedAt: dateOf(record?.updatedAt) ?? new Date() + } + await WIKI.db + .insert(commentsTable) + .values(values) + .onConflictDoUpdate({ target: commentsTable.id, set: values }) + imported++ + } + if (orphans.length > 0) { + warnings.push(describeOrphans('comments', orphans)) + } + return { imported, skipped, warnings } + } + + /** + * Navigation. + * + * 2.x keeps one tree per locale and 3.x has per-page navigation modes over menus owned by tree + * entries, so the honest equivalent is the one 3.x already treats as "the menu for this language": + * **the site-wide menu for that locale**, which every page in it inherits until something overrides + * one. `navigation.siteNavId` is what names it, and creates it if this is the first thing to ask. + * + * One record per locale, not one per item — the whole menu is a single `items` column, so a batch of + * halves would be a menu that flickers between them. + */ + async #navigationStream( + session: ImportSession, + target: ImportSessionSite, + records: any[] + ): Promise { + const warnings: string[] = [] + let imported = 0 + let skipped = 0 + + for (const record of records) { + const locale = stringOf(record?.localeCode).trim() + if (!locale || !Array.isArray(record?.items)) { + warnings.push( + describeSkip( + 'navigation', + missingOf({ localeCode: locale, items: Array.isArray(record?.items) }), + record + ) + ) + skipped++ + continue + } + const navId = await WIKI.models.navigation.siteNavId(target.siteId, locale) + const existing = await WIKI.db + .select({ items: navigationTable.items }) + .from(navigationTable) + .where(eq(navigationTable.id, navId)) + .limit(1) + const current = (existing[0]?.items ?? []) as NavigationItem[] + if (current.length > 0 && !session.overwrite) { + warnings.push( + `The ${locale} sidebar already has items and was left alone. Turn on "Overwrite on conflict" to replace it.` + ) + skipped++ + continue + } + + const items = await this.#translateNavItems(session, record.items, warnings) + await WIKI.db.update(navigationTable).set({ items }).where(eq(navigationTable.id, navId)) + imported++ + } + return { imported, skipped, warnings } + } + + /** 2.x navigation items → 3.x ones. 2.x trees are flat, so nothing here recurses. */ + async #translateNavItems( + session: ImportSession, + value: any[], + warnings: string[] + ): Promise { + const items: NavigationItem[] = [] + for (const entry of value) { + /* + No default. `kind` used to fall back to `link`, which meant anything at all that reached here + became a blank link — and what reached here, for two rounds of this, was 2.x's per-locale tree + WRAPPERS rather than its items. A sidebar of empty links reports success and draws nothing, + which is the worst way for a mapping to be wrong; an item that cannot say what it is is now + dropped and named. + */ + const type = NAV_KINDS[stringOf(entry?.kind)] + if (!type) { + warnings.push(describeSkip('sidebar item', ['kind'], entry)) + continue + } + const targetType = stringOf(entry?.targetType, 'page') + const isExternal = targetType === 'external' || targetType === 'externalblank' + items.push({ + id: uuidv5(`nav:${stringOf(entry?.id)}`, session.namespace), + type, + label: stringOf(entry?.label), + icon: normalizeIcon(entry?.icon), + target: targetType === 'home' ? '/' : stringOf(entry?.target), + openInNewWindow: + targetType === 'externalblank' || (isExternal && entry?.openInNewWindow === true), + visibilityGroups: + stringOf(entry?.visibilityMode) === 'restricted' + ? await this.#resolveGroups( + session, + entry?.visibilityGroups, + warnings, + 'A sidebar item' + ) + : [] + }) + } + return items + } + + // ============================================================================================== + // FINISHING + // ============================================================================================== + + /** + * Close a session: drop the staged blobs and say what happened. + * + * Nothing is rebuilt here. Every model the handlers went through did its own bookkeeping as it + * wrote — the tree entry, the storage-target copy, the search index, the queued render — so there is + * no deferred pass to run, only bytes to stop keeping. + */ + async finishSession(id: string): Promise<{ + progress: Record + warnings: string[] + pendingRenders: number + unrenderable: number + }> { + const session = await this.getSession(id) + if (!session) { + throw new CustomError('importNoSession', 'No such import session.', 404) + } + await this.#clearStaging(id) + await this.#patch(id, { state: 'finished' }) + + /* + What the operator is told to expect, which is the whole of §8 in one number: the render queue is + one headless browser doing one page at a time, and a page 3.x cannot render server-side keeps + its placeholder until somebody opens and saves it. Counted rather than estimated — a page whose + render came back while the import was still running is not still waiting. + */ + const siteIds = session.sites.map((entry) => entry.siteId) + const pending = siteIds.length + ? await WIKI.db + .select({ editor: pagesTable.editor, total: sql`count(*)::int` }) + .from(pagesTable) + .where( + and( + inArray(pagesTable.siteId, siteIds), + sql`coalesce(${pagesTable.render}, '') in ('', ${PENDING_RENDER_HTML})` + ) + ) + .groupBy(pagesTable.editor) + : [] + // -> `RENDERABLE_EDITORS` in `models/rendering.ts`, which is what the queue itself accepts + const renderable = new Set(['markdown', 'asciidoc']) + let pendingRenders = 0 + let unrenderable = 0 + for (const row of pending) { + if (BODYLESS_EDITORS.has(row.editor)) { + continue + } + if (renderable.has(row.editor)) { + pendingRenders += row.total + } else { + unrenderable += row.total + } + } + + return { + progress: session.progress, + warnings: session.warnings, + pendingRenders, + unrenderable + } + } + + async #clearStaging(sessionId: string): Promise { + try { + await fs.rm(this.stagingDir(sessionId), { recursive: true, force: true }) + } catch (err: any) { + WIKI.logger.warn(`Could not clear the import staging directory [ SKIPPED ]`) + WIKI.logger.warn(err.message) + } + } + + /** + * Drop the sessions nobody came back for. + * + * An import lives in a browser tab, and a tab that closed leaves an open session with its staged + * blobs on disk — which for a media wiki is however many gigabytes had been uploaded. Run daily + * alongside the audit log purge rather than on a timer of its own. + */ + async sweepSessions(): Promise { + const cutoff = new Date(Date.now() - SESSION_MAX_AGE_HOURS * 3600 * 1000) + const stale = await WIKI.db + .select({ id: importSessionsTable.id }) + .from(importSessionsTable) + .where(lt(importSessionsTable.updatedAt, cutoff)) + for (const row of stale) { + await this.#clearStaging(row.id) + } + if (stale.length > 0) { + await WIKI.db.delete(importSessionsTable).where( + inArray( + importSessionsTable.id, + stale.map((row) => row.id) + ) + ) + } + return stale.length + } +} + +export const importer = new Import() diff --git a/backend/models/index.ts b/backend/models/index.ts index 16789b09e..de9fcda4d 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -12,6 +12,7 @@ import { flags } from './flags.ts' import { groups } from './groups.ts' import { hooks } from './hooks.ts' import { icons } from './icons.ts' +import { importer } from './import.ts' import { jobs } from './jobs.ts' import { locales } from './locales.ts' import { mail } from './mail.ts' @@ -50,6 +51,7 @@ export default { groups, hooks, icons, + importer, jobs, locales, mail, diff --git a/backend/models/jobs.ts b/backend/models/jobs.ts index 2ca17480b..f49282386 100644 --- a/backend/models/jobs.ts +++ b/backend/models/jobs.ts @@ -40,6 +40,8 @@ export const SYSTEM_SCHEDULE: SystemScheduleEntry[] = [ // -> Daily, off the hour: the retention is expressed in days, so nothing is gained by looking // more often than once a day { task: 'purgeAuditLog', cron: '20 0 * * *' }, + // -> Same reasoning, and the cutoff is in hours but measured in days + { task: 'purgeImportSessions', cron: '25 0 * * *' }, { task: 'purgeRateLimits', cron: '10 * * * *' }, { task: 'updateLocales', cron: '0 0 * * *' }, // -> Every minute, and the task decides which sites are actually due: the interval is a per-site diff --git a/backend/models/pages.ts b/backend/models/pages.ts index b91120783..2949b5f22 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -2663,13 +2663,14 @@ class Pages { // -> A restore should not report every page as written today. Applied after the fact because the // two dates are not something an API client may set, only something a file can carry back. if (createdAt || updatedAt) { - await WIKI.db - .update(pagesTable) - .set({ - ...(createdAt ? { createdAt } : {}), - ...(updatedAt ? { updatedAt } : {}) - }) - .where(eq(pagesTable.id, page.id)) + const dates = { + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}) + } + await WIKI.db.update(pagesTable).set(dates).where(eq(pagesTable.id, page.id)) + // -> The tree row keeps its own pair, and those are the ones a folder listing and the file + // manager show. Left alone they said the whole of an imported wiki was created today. + await WIKI.db.update(treeTable).set(dates).where(eq(treeTable.id, page.id)) // -> Writing the page already put a copy on every target holding pages, stamped with the dates // it had for the moment it existed with the wrong ones const restored = this.toStoragePage( @@ -2748,9 +2749,17 @@ class Pages { permissions ) + /* + `updatedAt` is deliberately not touched. It says when the page last CHANGED, and a render does + not change it: the same source went through the pipeline again, because the markdown config + moved or because the page arrived without HTML. Bumping it here re-dated every page an import + brought in — the render lands seconds later and overwrites the date the package carried — and + told the sitemap a page had been modified when nothing about it had. For a save it made no + difference either way, since `updatePage` has already set it a moment earlier. + */ const updated = await WIKI.db .update(pagesTable) - .set({ render, toc, searchContent: text, updatedAt: sql`now()` }) + .set({ render, toc, searchContent: text }) .where(and(eq(pagesTable.id, id), eq(pagesTable.siteId, siteId))) .returning({ locale: pagesTable.locale }) diff --git a/backend/tasks/simple/purge-import-sessions.ts b/backend/tasks/simple/purge-import-sessions.ts new file mode 100644 index 000000000..1c1b1aaa9 --- /dev/null +++ b/backend/tasks/simple/purge-import-sessions.ts @@ -0,0 +1,24 @@ +/** + * Drop the import sessions nobody came back for, and the blobs they staged. + * + * An import lives in a browser tab (`dev/specs/wkbackup.md` §9 is honest about what that costs), so a + * tab that was closed halfway leaves an open session behind with however many gigabytes of uploaded + * assets waiting in its staging directory for metadata that is never coming. Nothing else will ever + * look at them. + * + * Daily and off the hour, alongside the other retention tasks: the cutoff is expressed in hours but + * measured in days, so there is nothing to gain by looking more often. + */ +export async function task(): Promise { + WIKI.logger.info('Purging abandoned import sessions...') + + try { + const purged = await WIKI.models.importer.sweepSessions() + + WIKI.logger.info(`Purged ${purged} abandoned import sessions: [ COMPLETED ]`) + } catch (err: any) { + WIKI.logger.error('Purging abandoned import sessions: [ FAILED ]') + WIKI.logger.error(err.message) + throw err + } +} diff --git a/dev/specs/wkbackup.md b/dev/specs/wkbackup.md index 476634ba6..d2e3b49b3 100644 --- a/dev/specs/wkbackup.md +++ b/dev/specs/wkbackup.md @@ -1,7 +1,12 @@ # `.wkbackup` — backup and migration package format -**Status:** proposed. Nothing in this document is implemented. The open questions it carried are -answered — [§10](#10-decisions-taken). +**Status:** the v3 importer is implemented for `source.kind: "wikijs2"`; the v2 exporter that writes +the packages it reads is not, and neither is the 3.x export or a `wikijs3` restore. The open questions +this document carried are answered — [§10](#10-decisions-taken) — and what implementing the importer +changed is recorded in [§11](#11-amendments-made-while-implementing-the-v3-importer). +**The record shapes are [§12](#12-the-wikijs2-stream-records)**, and that section is written FROM the +2.x exporter (`server/core/backup.js` in the 2.x repository) rather than the other way round: it ships +already, so it is the authority and this reader is held to it. **Covers:** Wiki.js 2.x → 3.x migration, and 3.x → 3.x backup/restore. A `.wkbackup` is one file holding the whole content of a wiki — pages, history, assets, users, groups, @@ -494,3 +499,362 @@ the section that acts on it. 6. **There is no retention question.** The package is a download that the browser reads from the operator's own disk; no server ever holds it for the import — [§6](#6-the-v2-exporter), [§7](#7-the-v3-importer). + +--- + +## 11. Amendments made while implementing the v3 importer + +The importer described in [§7](#7-the-v3-importer) is implemented for `source.kind: "wikijs2"` +(`backend/models/import.ts`, `backend/api/import.ts`, `frontend/src/helpers/wkbackup/`). Three things +in this document did not survive contact with the code, and the reasons are here rather than in a +commit message. + +### Blobs are uploaded before the metadata that references them + +[§7](#order-of-operations) ordered a site's streams `pages → page-history → assets → comments`, with +assets as "metadata, then blobs". That order cannot be run. An asset is created by +`assets.adoptStoredFile`, which takes the bytes — there is no half-created asset for a later upload +to fill in — and the same section requires a blob to be uploaded **once** however many records +reference it, which a blob-per-asset request cannot express either. + +So a blob is staged before the batch that references it is posted: + +``` +locales → groups → users + → per site: tree → pages → page-history → assets → comments → navigation + → finish +``` + +and for each batch of any stream, the blobs its records name (`blob`, `contentBlob`) are uploaded +first, once each across the whole import. Demand-driven rather than a phase of its own, because +`blobs/` holds every file in the wiki and an import of pages alone has no business moving eight +gigabytes of images nobody asked for. Content addressing is what makes that safe to decide a batch at +a time — "have I already sent this?" is a question about the digest and nothing else. + +Staging is `/cache/import//`, which is a cache in the sense +`/cache/blocks` is: derived, disposable, and removed by `finish` (and by a sweep of +sessions older than `SESSION_MAX_AGE_HOURS`, for the import that never finished). Every blob's +SHA-256 is verified on arrival — the name *is* the checksum, so a reader that did not check it would +be trusting the uploader about the one thing the naming scheme exists to establish. + +### The reading is not in a Web Worker of the importer's own + +[§7](#frontend) called for one. `@zip.js/zip.js` already runs inflation in a pool of its own workers +by default, which is the part that would jank the main thread; what is left on it is a line split and +a `JSON.parse` per batch of 500 records, between `await`ed uploads that dominate the wall clock by +orders of magnitude. A second worker layer would have to re-export the session's cookie-authenticated +uploads across a message port to buy nothing measurable. + +### Identity is natural keys where 2.x has one, derived UUIDs where it does not + +[§4](#4-identity-derive-uuids-do-not-map-them) derives every id. That is right for a 3.x restore, +where the package and the target speak the same schema; for a 2.x migration most records have a +natural key in 3.x and using it is what makes the import *converge* with a wiki that is already +running rather than shadowing it: + +| Record | Matched on | +| --- | --- | +| user | `email`, lowercased — see below | +| group | `name` | +| page | `siteId` + `locale` + `path` | +| folder | `siteId` + `locale` + `path` | +| asset | `siteId` + `locale` + folder path + file name | +| navigation | `siteId` + `locale` — the site-wide menu | +| comment, page history | derived: `uuidv5(session.namespace, "::")` | + +The last row is what [§4](#4-identity-derive-uuids-do-not-map-them) is for and keeps its property: +those two are the records with nothing in them that identifies a row, so replaying a batch upserts +rather than duplicates. + +**The namespace is derived, not issued.** §4 has the server mint one per session, which makes a batch +safe to *retry* and nothing more. There is no resume button: an import that fell over is re-run from +the top, and with a fresh namespace each run that means a second copy of every comment and every +history entry — precisely the two kinds with no natural key to save them. So it is +`uuidv5(source-kind | source-instance-id | each package-site > target-site, fixed root)`, which is +the same value next week and on a rebuilt instance, and a different one for another wiki's package or +another target site. + +**A user is matched on the email address and nothing else.** Not the display name, which is not +unique and which people change; not the 2.x row id, which means nothing here. An address that already +has an account IS that person, so the import fills in what the account is missing rather than making +a second one beside it. + +**The account running the import is never written to**, whatever `overwrite` says. It is in practice +the root administrator, it is the session the rest of the import is authenticated by, and the package +was produced by a wiki whose copy of that address may carry a different password hash, a different +2FA secret, or `isActive: false`. Overwriting it mid-import is how an operator locks themselves out +of the instance they are migrating into, with thousands of records still to write. It is reported in +the log rather than passed over quietly. + +**2.x system groups are mapped, not created.** `Administrators` (id 1) and `Guests` (id 2) exist in +3.x already, with rules of their own that are not 2.x's to overwrite; their *memberships* are what +carries, onto `systemIds.administratorsGroupId` and `systemIds.guestsGroupId`. + +**And the ids have to be joined up somewhere.** A record from 2.x references other records by 2.x's +integers — a page's `authorId`, a comment's `pageId`, a user's `groups` — while this wiki matches on +natural keys, so neither end can resolve the other alone. `importIdMap` is a row per user, page and +group, written as the owning stream runs and read in bulk by the streams after it. A table rather +than a blob on the session because users and pages are unbounded: a jsonb column rewritten once per +batch is megabytes of write amplification by the end of a large import. Rows go with the session, +which is what stops it becoming a permanent record of somebody's old instance. + +### Settings are not imported + +[§10](#10-decisions-taken) item 5 stands as the design, and `streams/settings.json` is read far +enough to be reported in the log, but nothing is applied: which 2.x key means what in 3.x is a table +of its own and is being settled separately. The overlay offers no Settings tick. + +--- + +## 12. The `wikijs2` stream records + +**This section is written from the exporter, not the other way round.** It is +`server/core/backup.js` in the 2.x repository, and the importer here is read against it. An earlier +revision of this section guessed at the field names and every one of the guesses was wrong, which +cost a migration that reported success and imported nothing — so the rule is that a change to this +section follows a change to that file, never precedes it. + +### A record is a 2.x database row + +The exporter selects a table and writes it out. It renames nothing and resolves nothing, which is the +right division of labour — it cannot know which version will read the package — and it means three +things hold everywhere below: + +- **The field names are 2.x's own column names.** `localeCode`, not `locale`. `editorKey`, not + `editor`. `filename`, not `fileName`. +- **The ids are 2.x's own integers**, and every cross-reference is one: a page's `authorId`, a + comment's `pageId`, a user's `groups`. They mean nothing in a 3.x database, so the importer keeps an + `importIdMap` row per user, page and group as the owning stream runs, and the streams after it + resolve through that — see [§11](#identity-is-natural-keys-where-2x-has-one-derived-uuids-where-it-does-not). +- **Columns that mean nothing here come along anyway** — `hash`, `privateNS`, `isPrivate`, + `contentType`, `render` on a comment. They are ignored. Nothing is refused for carrying more than + this reader knows about, which is what lets a 2.x point release add a column without breaking + every import. + +Every stream is `schema: 1`. A record missing a field this reader actually needs is skipped with a +warning that **names the missing field and lists the keys the record did carry** — the single most +useful thing a log can say when a package and a reader disagree, and the thing whose absence made the +first failure unreadable. + +### `streams/locales.ndjson` + +Rows of 2.x's `locales`, plus two flags the exporter adds: + +```json +{ "code": "en", "name": "English", "nativeName": "English", "isRTL": false, + "availability": 100, "isPrimary": true, "isActive": true } +``` + +Only `code` is read. The importer checks each against the locales installed here and reports the ones +that are not; it installs nothing, because that is a download from a third party in the middle of +somebody's migration. + +### `streams/groups.ndjson` + +Rows of 2.x's `groups`: + +```json +{ "id": 5, "name": "Editors", "isSystem": false, + "permissions": ["read:pages", "write:pages", "manage:api"], + "pageRules": [ + { "id": "rul3", "path": "docs", "roles": ["read:pages"], "match": "START", + "deny": false, "locales": ["en"] } + ], + "redirectOnLogin": "/", "createdAt": "…", "updatedAt": "…" } +``` + +2.x keeps one permission list where 3.x keeps two, so `permissions` carries page permissions alongside +global ones. A page permission is dropped from the group's global list **in silence** — it is not a +meaningless name, it is a name that lives in `rules` here, and `pageRules` is where it arrives. A name +in neither vocabulary is dropped **loudly**, which in practice means `manage:api`. The exporter warns +about those in `manifest.warnings` too, having checked against the same list. + +`deny` becomes `mode: "DENY"`, otherwise `"ALLOW"`; 2.x has no `FORCEALLOW`. `match` carries across +unchanged for the kinds both versions have (`START`, `EXACT`, `END`, `REGEX`, `TAG`) and a rule using +anything else is dropped, named. Every imported rule is **scoped to the target site** — a rule from a +wiki that had one site must not start speaking for the others here. + +`Administrators` (id 1) and `Guests` (id 2) are mapped onto 3.x's own rather than created; only their +memberships carry. + +### `streams/users.ndjson` + +Rows of 2.x's `users`, with `groups` flattened to a list of group ids: + +```json +{ "id": 3, "email": "ana@example.com", "name": "Ana", + "providerKey": "local", + "password": "$2a$12$…", + "tfaIsActive": true, "tfaSecret": "JBSWY3DPEHPK3PXP", "mustChangePwd": false, + "isSystem": false, "isActive": true, "isVerified": true, + "localeCode": "en", "jobTitle": "", "location": "", "timezone": "", + "groups": [5], "createdAt": "…", "updatedAt": "…" } +``` + +`password` and `tfaSecret` carry over verbatim into `users.auth[]` +([§10](#10-decisions-taken) item 2) — but only for `providerKey: "local"`. An account that +authenticated somewhere else arrives with no credentials at all and the log says which strategy has +to be configured before they can sign in. + +`localeCode` becomes `prefs.locale`; `jobTitle`, `location` and `timezone` become `meta`. A record with +`isSystem` is skipped — 3.x seeds its own guest. + +Matching is on **`email`, lowercased**, and the account running the import is never written to. Both +are [§11](#identity-is-natural-keys-where-2x-has-one-derived-uuids-where-it-does-not). + +### `sites//tree.ndjson` + +Rows of 2.x's `pageTree` where `isFolder`: + +```json +{ "id": 10, "path": "docs/guides", "depth": 2, "title": "Guides", + "isPrivate": false, "privateNS": null, "parent": 10, "localeCode": "en" } +``` + +Pages and assets create the folders they need on the way in, so this stream is for the two things +they cannot supply: the title somebody gave a folder, and a folder with nothing in it. + +### `sites//pages.ndjson` + +Rows of 2.x's `pages` minus `render` and `toc`, with `tags` flattened to a list of strings: + +```json +{ "id": 42, "path": "docs/intro", "localeCode": "en", + "title": "Introduction", "description": "The first page", + "editorKey": "markdown", "contentType": "markdown", + "isPublished": true, "isPrivate": false, + "tags": ["onboarding"], + "content": "# Hello…", "contentBlob": null, + "authorId": 3, "creatorId": 3, + "createdAt": "…", "updatedAt": "…" } +``` + +**No render**, which is the exporter's own decision and the right one +([§8](#8-the-bottleneck-that-shapes-the-schedule)). `contentBlob` is a `blobs/` digest and +replaces `content` (set to `null`) when the source is over 1 MiB. + +`editorKey` maps `markdown → markdown`, `asciidoc → asciidoc`, `redirect → redirect`, and 2.x's two +HTML editors (`ckeditor`, `code`) onto **markdown** rather than 3.x's `visual`: markdown is configured +with `allowHTML`, so a body of HTML renders as it stood, where `visual` is a WYSIWYG over markdown and +would be handed a document it does not parse. Every page that takes that road is named in the log. + +A 2.x redirection's `content` is the target path; 3.x holds a redirection as JSON, so it is rewritten. + +### `sites//page-history.ndjson` + +Rows of 2.x's `pageHistory`, with `tags`: + +```json +{ "id": 900, "pageId": 42, "path": "docs/intro", "localeCode": "en", + "action": "updated", "title": "Introduction", "description": "", + "editorKey": "markdown", "isPublished": true, + "content": "…", "contentBlob": null, + "authorId": 3, "versionDate": "…", "createdAt": "…" } +``` + +The page is resolved by **`pageId` through the id map**, not by the path on the record. A history row +records the path the page had AT THE TIME — that is what the column is for, here as much as in 2.x — +so every version written before a page was renamed carries its old path, and matching on that throws +away the history of exactly the pages whose history is most worth having. `pageId` survives a rename. +The path is still what gets **stored**, since "where was this page when this version was written" is +what a history list shows and what finding a deleted page again depends on. Falling back to +`localeCode` + `path` covers a package whose history is imported over pages that are already here. + +History must therefore be written after the pages, which the stream order already requires. A row with +nothing to attach to is skipped and **said so**, with a count and examples rather than a line each: +either the pages were not imported, or the page was deleted in the source wiki, which keeps its +history deliberately so that a deleted page can be recovered. + +The row's own id is derived, since history has no natural key, so a re-run updates rather than +duplicates. + +### `sites//assets.ndjson` + +Rows of 2.x's `assets` minus the bytes, plus two fields the exporter adds: + +```json +{ "id": 77, "filename": "logo.png", "ext": ".png", "kind": "image", + "mime": "image/png", "fileSize": 4211, "metadata": {}, + "folderId": 2, "folderPath": "docs/img", "blob": "e3b0c442…", + "authorId": 3, "createdAt": "…", "updatedAt": "…" } +``` + +**There is no locale on an asset.** 2.x files assets in a tree of their own that has no locales in it, +so there is nothing to read and nothing to guess: they go in the target site's **primary locale**, +which is where a one-language 3.x site keeps everything anyway. + +`folderPath` is resolved by the exporter from `folderId`, because 2.x's asset folders are a table the +package does not otherwise carry. `blob` names an entry under `blobs/` that must be uploaded first +([§11](#blobs-are-uploaded-before-the-metadata-that-references-them)). `mime` and `fileSize` are read +for the log and nothing else — 3.x derives both from the name and the bytes it actually received. + +### `sites//comments.ndjson` + +Rows of 2.x's `comments`: + +```json +{ "id": 5, "pageId": 42, + "content": "Looks good to me.", "render": "

…

", + "name": "Ana", "email": "ana@example.com", "ip": "10.0.0.1", + "authorId": 3, "createdAt": "…", "updatedAt": "…" } +``` + +**The page is named by 2.x page id and nothing else** — there is no path on the row — so the pages +stream has to have run and recorded what each one became. A comment whose page was not imported is +skipped and named. + +`name`, `email` and `ip` are the commenter's own, kept by 2.x for guests and signed-in authors alike. +`authorId` resolves to an account here; when it does not, the comment stays a guest comment under that +name and address, which is what it already was for anybody who commented without signing in. `render` +is ignored — 3.x renders a comment in the browser at display time. + +2.x comments are flat, so nothing carries a `parentId`. The row's own id is derived, as page history's +is and for the same reason. + +### `sites//navigation.json` + +2.x's `navigation` table, keyed by its `key` column: + +```json +{ "mode": "MIXED", + "trees": { + "site": [ + { "locale": "en", + "items": [ + { "id": "n1", "kind": "link", "label": "Home", "icon": "mdi-home", + "targetType": "home", "target": "/", + "visibilityMode": "all", "visibilityGroups": [] } + ] }, + { "locale": "fr", "items": [ … ] } + ] + } } +``` + +**Neither half of that key/value pair is what it looks like**, and reading either one wrong produces a +sidebar that imports, reports success and then draws nothing. + +- **The key is not a locale.** 2.x keeps its one site-wide tree under the literal `site` — its + navigation model is `idColumn = 'key'` and fetches with `findOne('key', 'site')`. Nothing about the + key says anything about language. +- **The value is not a list of items.** It is a list of **per-locale trees**, `[{ locale, items }]`, + which is what that same model iterates to fill its `nav:sidebar:` cache. The locale comes + from inside each entry, so the key carries no information the reader needs at all. + +One exception, and it is 2.x's own: a config whose **first entry has a `kind`** is the pre-2.3 flat +format, a bare array of items, which 2.x reads as the `en` tree. The exporter writes the column raw, +so a wiki that has not re-saved its navigation since 2.2 still exports that shape. + +Each tree becomes **the 3.x site-wide menu for its locale** ([§6](#mapping-notes)) — the sidebar every +page in that locale inherits. The importer logs the item count per locale, because this is a mapping +whose failure is silent. + +`kind` maps `link → link`, `header → header`, `divider → separator`, **with no default**: an item that +cannot say what it is is dropped and named, rather than becoming a blank link. `targetType` maps +`home` and `page` onto a path and `external`/`externalblank` onto the URL, the latter with +`openInNewWindow`. A `mdi-home` or `las la-cog` icon is rewritten to its Iconify spelling rather than +stored as a webfont name 3.x no longer ships. `visibilityMode: "restricted"` carries its groups +through the id map. + +### `sites//site.json` and `streams/settings.json` + +Read far enough to be reported, not applied — [§11](#settings-are-not-imported). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a28fffb60..7d4d19b29 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@twemoji/api": "17.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "@zip.js/zip.js": "2.17.0", "browser-fs-access": "0.38.0", "clipboard": "2.0.11", "es-toolkit": "1.50.0", @@ -3745,6 +3746,17 @@ "addons/*" ] }, + "node_modules/@zip.js/zip.js": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.17.0.tgz", + "integrity": "sha512-WxvSsJvlI+5v4f1gWjGfqvzOB8XkAIughO+iJUPOJR6OSpUkFauBzi1G4xS2MmK1fxHGuDAqq9Lnpq/ri7BWuw==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, "node_modules/acorn": { "version": "8.18.0", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 17d904003..08d8a3f78 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@twemoji/api": "17.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "@zip.js/zip.js": "2.17.0", "browser-fs-access": "0.38.0", "clipboard": "2.0.11", "es-toolkit": "1.50.0", diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 08375fa9c..b45f51d44 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 276 icons. + 277 icons. */ export const BUNDLED_ICONS = { "la:angle-down": {"body":"","width":32,"height":32}, @@ -113,6 +113,7 @@ export const BUNDLED_ICONS = { "la:pen-fancy": {"body":"","width":32,"height":32}, "la:pen-nib": {"body":"","width":32,"height":32}, "la:play": {"body":"","width":32,"height":32}, + "la:play-circle": {"body":"","width":32,"height":32}, "la:plug": {"body":"","width":32,"height":32}, "la:plus": {"body":"","width":32,"height":32}, "la:plus-circle": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/ImportWikijs2Overlay.vue b/frontend/src/components/ImportWikijs2Overlay.vue index d56443e64..d579e3a49 100644 --- a/frontend/src/components/ImportWikijs2Overlay.vue +++ b/frontend/src/components/ImportWikijs2Overlay.vue @@ -1,32 +1,47 @@ diff --git a/frontend/src/helpers/wkbackup.js b/frontend/src/helpers/wkbackup.js new file mode 100644 index 000000000..9879f7c9a --- /dev/null +++ b/frontend/src/helpers/wkbackup.js @@ -0,0 +1,188 @@ +import { BlobReader, TextWriter, Uint8ArrayWriter, ZipReader } from '@zip.js/zip.js' + +/** + * Reading a `.wkbackup` package in the browser. + * + * The format is `dev/specs/wkbackup.md`; §12 of it is the contract for the records this yields. The + * package is never uploaded — the browser holds the `File`, walks it, and drives the import API a + * batch at a time — so everything here is about getting at one part of a possibly enormous archive + * without materialising the rest. + * + * ZIP earns its keep for exactly that reason: a local `File` is a `Blob` with random access, so the + * central directory at the end of the file is an asset rather than the liability it is over a socket. + * `manifest.json` is readable in the first second whatever the other eight gigabytes weigh. + * + * `@zip.js/zip.js` runs inflation in a pool of workers of its own, which is the part that would jank + * the log this is drawing. What is left on the main thread is a line split and a `JSON.parse` per + * batch, between uploads that dominate the wall clock by orders of magnitude. + */ + +/** Refused by the reader rather than by the server, since the server never sees the file. */ +export class PackageError extends Error {} + +/** + * Open a package and read its manifest. + * + * @returns `{ manifest, entry(path), has(path), close() }`. `entry` is how every other function here + * is reached — a stream is named by the manifest, never guessed at from a convention. + */ +export async function openPackage(file) { + const reader = new ZipReader(new BlobReader(file)) + let entries + try { + entries = await reader.getEntries() + } catch (err) { + await reader.close().catch(() => {}) + throw new PackageError( + `This file could not be opened as a Wiki.js backup package. (${err.message})` + ) + } + + const byName = new Map() + for (const entry of entries) { + if (!entry.directory) { + byName.set(entry.filename, entry) + } + } + + const manifestEntry = byName.get('manifest.json') + if (!manifestEntry) { + await reader.close().catch(() => {}) + throw new PackageError( + 'The package has no manifest.json, so it is not a Wiki.js backup package.' + ) + } + + let manifest + try { + manifest = JSON.parse(await manifestEntry.getData(new TextWriter())) + } catch (err) { + await reader.close().catch(() => {}) + throw new PackageError(`The package manifest could not be read. (${err.message})`) + } + + return { + manifest, + entry: (path) => byName.get(path) ?? null, + has: (path) => byName.has(path), + close: () => reader.close().catch(() => {}) + } +} + +/** One whole entry as parsed JSON, for the small documents (`navigation.json`, `settings.json`). */ +export async function readJson(entry) { + return JSON.parse(await entry.getData(new TextWriter())) +} + +/** One whole entry as bytes. Used for blobs, which are stored rather than deflated. */ +export async function readBytes(entry) { + return entry.getData(new Uint8ArrayWriter()) +} + +/** + * The records of an NDJSON stream, one at a time. + * + * Streamed rather than read whole: `pages.ndjson` for a large wiki is hundreds of megabytes, and the + * point of one entry per stream instead of one per page is that it can be walked in a single pass. + * + * A line that will not parse is **skipped**, not thrown — one corrupt line in ninety thousand is not + * a reason to abandon a migration — and reported through `onMalformed` so it reaches the log. + */ +export async function* readRecords(entry, { onMalformed } = {}) { + const stream = new TransformStream() + // -> Kicked off, not awaited: it resolves when the last byte has been written, which is after the + // loop below has consumed it. Its rejection is claimed here so it is never unhandled. + const finished = entry.getData(stream.writable) + finished.catch(() => {}) + + const reader = stream.readable.pipeThrough(new TextDecoderStream()).getReader() + let buffer = '' + try { + for (;;) { + const { value, done } = await reader.read() + if (done) { + break + } + buffer += value + let breakAt = buffer.indexOf('\n') + while (breakAt >= 0) { + const line = buffer.slice(0, breakAt).trim() + buffer = buffer.slice(breakAt + 1) + if (line) { + const record = parseLine(line, onMalformed) + if (record) { + yield record + } + } + breakAt = buffer.indexOf('\n') + } + } + // -> A writer that did not end the file with a newline still wrote a record + const tail = buffer.trim() + if (tail) { + const record = parseLine(tail, onMalformed) + if (record) { + yield record + } + } + } finally { + reader.cancel().catch(() => {}) + } + await finished +} + +function parseLine(line, onMalformed) { + try { + return JSON.parse(line) + } catch { + onMalformed?.(line) + return null + } +} + +/** + * Group an async iterable into batches, bounded by BOTH a record count and a rough payload size. + * + * Batching is what keeps the import to one request per few hundred records instead of one per record, + * and it is also why nothing here ever holds a whole stream: only the batch in flight is resident. + * + * The size bound is the one that matters. A count alone is fine for rows — a locale, a group, a tree + * entry — and hopeless for the two streams that carry whole documents: 500 page-history records off a + * real wiki is tens of megabytes, which is a request no sane body limit accepts. Counting records was + * how this shipped and how it fell over on the first package with a page history in it. + * + * `JSON.stringify(...).length` is UTF-16 code units rather than bytes, so it UNDER-counts by up to 3x + * for text that is not Latin — which is why the budget the caller passes sits well under what the + * server will take, and why the caller also halves a batch that comes back 413 rather than trusting + * this. An item bigger than the budget on its own is yielded alone: a record cannot be split, and one + * enormous page is the server's business to accept or refuse. + */ +export async function* inBatches(iterable, { maxRecords, maxBytes }) { + let batch = [] + let bytes = 0 + for await (const item of iterable) { + const size = JSON.stringify(item).length + if (batch.length > 0 && (batch.length >= maxRecords || bytes + size > maxBytes)) { + yield batch + batch = [] + bytes = 0 + } + batch.push(item) + bytes += size + } + if (batch.length > 0) { + yield batch + } +} + +/** + * The SHA-256 of some bytes, as lowercase hex. + * + * Used to check a blob against the name it is filed under before it is uploaded: the name being the + * checksum is the whole of what content addressing buys, and a reader that took it on trust would be + * uploading whatever the file happened to hold under a digest the server would then reject. + */ +export async function sha256Hex(bytes) { + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/frontend/src/helpers/wkbackupImport.js b/frontend/src/helpers/wkbackupImport.js new file mode 100644 index 000000000..85d5293a5 --- /dev/null +++ b/frontend/src/helpers/wkbackupImport.js @@ -0,0 +1,425 @@ +import { inBatches, openPackage, readBytes, readJson, readRecords, sha256Hex } from './wkbackup' + +/** + * Driving an import of a `.wkbackup` package from the browser. + * + * `dev/specs/wkbackup.md` is the design and §11 has the order of operations this follows. The reader + * is `helpers/wkbackup.js`; this is the half that knows what the records MEAN and which endpoint each + * stream goes to. + * + * It reports as it goes rather than returning at the end: an import of a large wiki runs for hours, + * so `log` and `onProgress` are how it is watched, and the value it resolves to is only the summary. + */ + +/** Records per request. The server's own ceiling — see `MAX_BATCH_RECORDS` in `models/import.ts`. */ +const BATCH_SIZE = 500 + +/** + * What a batch aims to weigh, in UTF-16 code units of JSON. + * + * A quarter of the server's `MAX_BATCH_BYTES`, and the gap is deliberate: the measure under-counts + * bytes by up to 3x for text outside Latin-1, so a budget at the ceiling would be a 413 for every + * wiki written in Chinese, Japanese, Korean, Greek, Cyrillic or Arabic. `postBatch` halves anything + * that is refused anyway, so this only has to be close. + */ +const BATCH_BYTES = 4 * 1024 * 1024 + +/** + * The order the streams are walked in, and it is load-bearing. + * + * Instance-wide first: a page needs an author and a comment needs one too, and both are resolved by + * email against the accounts that exist at the time — so a users stream that ran afterwards would + * leave every record attributed to whoever pressed the button. Groups before users for the same + * reason one rung down, since a user record names the groups it belongs to. + * + * Then, per site: folders (so a folder somebody titled *Guides* is not created as *guides* by the + * first page filed under it), pages, then the history and comments that hang off them, then assets, + * then navigation. + */ +const INSTANCE_STREAMS = [ + { name: 'locales', requires: null }, + { name: 'groups', requires: 'groups' }, + { name: 'users', requires: 'users' } +] + +const SITE_STREAMS = [ + { name: 'tree', requires: null }, + { name: 'pages', requires: 'pages' }, + { name: 'page-history', requires: 'history' }, + { name: 'assets', requires: 'assets' }, + { name: 'comments', requires: 'comments' } +] + +/** + * Import one package. + * + * @param file The `.wkbackup` the operator picked + * @param siteId The site on this instance everything site-scoped lands in + * @param includes The content kinds ticked in the overlay + * @param overwrite Whether an existing record is replaced + * @param log `(level, message)` — `info` / `success` / `warn` / `error` + * @param onProgress `(fraction)` from 0 to 1 + */ +export async function runImport({ file, siteId, includes, overwrite, log, onProgress }) { + const pkg = await openPackage(file) + try { + return await drive({ pkg, siteId, includes, overwrite, log, onProgress }) + } finally { + await pkg.close() + } +} + +async function drive({ pkg, siteId, includes, overwrite, log, onProgress }) { + const { manifest } = pkg + log( + 'info', + `Package written ${manifest.createdAt ?? 'at an unknown date'} by ${describeGenerator(manifest)}.` + ) + + /* + Everything answerable from the manifest, before a single record is written. The server owns this + question rather than the browser: what this wiki can read is what this wiki knows. + */ + const preflight = await API_CLIENT.post('import/preflight', { + json: { manifest, siteId } + }).json() + for (const warning of preflight.warnings) { + log('warn', warning) + } + if (!preflight.ok) { + for (const error of preflight.errors) { + log('error', error) + } + throw new Error(preflight.errors[0] ?? 'This package cannot be imported.') + } + log('info', 'Package checked.') + + // -> A 2.x package always holds exactly one site; a multi-site package is reported by preflight + const packageSite = manifest.sites[0] + const sourceId = String(packageSite.id ?? 'default') + + const session = await API_CLIENT.post('import/sessions', { + json: { + source: manifest.source.kind, + // -> Part of what the server derives this import's record ids from, so that running the same + // package again after a failure upserts instead of laying down a second copy of everything + // that has no natural key + sourceInstanceId: manifest.source.instanceId ?? '', + sites: [{ sourceId, siteId }], + includes, + overwrite + } + }).json() + log('info', `Import session opened.`) + + const plan = buildPlan({ manifest, packageSite, includes, log }) + const progress = { done: 0, total: Math.max(plan.total, 1) } + const advance = (n) => { + progress.done += n + onProgress(Math.min(1, progress.done / progress.total)) + } + onProgress(0) + + /** Blobs already staged this session, so one shared by forty records is uploaded once. */ + const staged = new Set() + + for (const step of plan.steps) { + const label = step.label + log('info', `${label}: starting (${step.count === null ? 'unknown' : step.count} records)...`) + const totals = { imported: 0, skipped: 0 } + + let read = 0 + if (step.kind === 'json') { + const records = await step.read(pkg) + read = records.length + const result = await postBatch(step.url(session, siteId), records, label, log) + tally(totals, result, log) + advance(step.count ?? records.length) + } else { + for await (const batch of inBatches( + readRecords(pkg.entry(step.path), { + onMalformed: () => log('warn', `${label}: a record could not be read and was skipped.`) + }), + { maxRecords: BATCH_SIZE, maxBytes: BATCH_BYTES } + )) { + read += batch.length + await stageBlobsFor({ pkg, session, batch, staged, log }) + const result = await postBatch(step.url(session, siteId), batch, label, log) + tally(totals, result, log) + advance(batch.length) + } + } + + /* + The manifest counts the records the exporter meant to write; this is how many were actually + there. When they disagree the package is short, and saying so plainly is the difference between + "the import lost my pages" and "the export only wrote nine of them" — which are fixed in + completely different places, and only one of them here. + + A shortfall cascades: everything hanging off the missing records is skipped too, so the log + below this line fills with history and comments that have nowhere to go. This is the line that + explains those, which is why it says what it says. + */ + if (step.count !== null && read !== step.count) { + const verb = read < step.count ? 'only held' : 'held' + log( + 'warn', + `${label}: the package says it contains ${step.count} records but the stream ${verb} ${read}. The package itself is incomplete \u2014 nothing below can import what is not in it.` + ) + } + log('info', `${label}: ${totals.imported} imported, ${totals.skipped} skipped.`) + } + + const summary = await API_CLIENT.post(`import/sessions/${session.id}/finish`).json() + reportSummary(summary, log) + onProgress(1) + return summary +} + +/** + * What will be read, and how many records that is. + * + * Built up front from the manifest's counts, which is what makes the progress bar a real fraction + * rather than a spinner — the counts are in the central directory's first entry, so they are known + * before anything has been inflated. + */ +function buildPlan({ manifest, packageSite, includes, log }) { + const steps = [] + let total = 0 + + const add = (step) => { + steps.push(step) + total += step.count ?? 0 + } + + for (const { name, requires } of INSTANCE_STREAMS) { + const declared = manifest.streams?.[name] + if (!declared?.path || (requires && !includes.includes(requires))) { + continue + } + add({ + kind: 'ndjson', + label: labelFor(name), + path: declared.path, + count: declared.count ?? null, + url: (session) => `import/sessions/${session.id}/streams/${name}` + }) + } + + for (const { name, requires } of SITE_STREAMS) { + const declared = packageSite.streams?.[name] + if (!declared?.path || (requires && !includes.includes(requires))) { + continue + } + add({ + kind: 'ndjson', + label: labelFor(name), + path: declared.path, + count: declared.count ?? null, + url: (session, siteId) => `import/sessions/${session.id}/sites/${siteId}/streams/${name}` + }) + } + + /* + Navigation is one document rather than a stream — a whole menu is a single column here, so a batch + of halves would be a sidebar that flickered between them. + + `{ mode, trees: { : } }` is what the 2.x exporter writes, keyed by 2.x's + `navigation.key`. **Neither half of that key/value pair is what it looks like.** + + The key is not a locale: 2.x keeps its one site-wide tree under the literal `site` + (`idColumn = 'key'`, `findOne('key', 'site')` in its navigation model). And the value is not a + list of menu items — it is a list of PER-LOCALE TREES, `[{ locale, items }]`, which is what that + model iterates to fill `nav:sidebar:`. So the locale comes from inside the config and the + key carries nothing at all. + + Reading the config as items produces one blank link per locale, which imports, reports success and + draws an empty sidebar. That is what this looked like the first two times. + + The one exception is 2.x's own: a config whose first entry has a `kind` is the pre-2.3 flat format, + and 2.x reads that as locale `en`. Handled here because the exporter writes the column raw, so a + wiki that has not re-saved its navigation since 2.2 still exports the old shape. + */ + const nav = packageSite.streams?.navigation + if (nav?.path && includes.includes('navigation')) { + add({ + kind: 'json', + label: labelFor('navigation'), + count: null, + url: (session, siteId) => `import/sessions/${session.id}/sites/${siteId}/streams/navigation`, + read: async (pkg) => { + const entry = pkg.entry(nav.path) + if (!entry) { + return [] + } + const document = await readJson(entry) + const primary = packageSite.locales?.length > 0 ? packageSite.locales[0] : 'en' + const trees = [] + for (const config of Object.values(document?.trees ?? {})) { + if (!Array.isArray(config) || config.length < 1) { + continue + } + // -> Pre-2.3: a flat list of items, which 2.x itself reads as the `en` tree + if (config[0]?.kind) { + trees.push({ localeCode: 'en', items: config }) + continue + } + for (const tree of config) { + if (Array.isArray(tree?.items)) { + trees.push({ localeCode: tree.locale || primary, items: tree.items }) + } + } + } + /* + Said out loud per tree. A sidebar that lands in the wrong locale, or that is built out of + the wrong level of the document, is invisible rather than wrong — it reports success and + draws nothing — so the log has to carry enough to tell those apart without a database. + */ + for (const tree of trees) { + log('info', `Navigation: ${tree.items.length} items for the ${tree.localeCode} sidebar.`) + } + if (trees.length < 1) { + log('warn', 'Navigation: the package holds no menu this wiki could read.') + } + return trees + } + }) + } + + return { steps, total } +} + +/** + * Upload the blobs a batch is about to reference, once each. + * + * Demand-driven rather than a phase of its own: a package's `blobs/` holds every asset in the wiki, + * and an import of pages alone has no business uploading eight gigabytes of images nobody asked for. + * Content addressing is what makes this safe to do per batch — "have I already sent this?" is a + * question about the digest and nothing else. + */ +async function stageBlobsFor({ pkg, session, batch, staged, log }) { + const wanted = new Set() + for (const record of batch) { + for (const digest of [record?.blob, record?.contentBlob]) { + if (typeof digest === 'string' && digest && !staged.has(digest)) { + wanted.add(digest) + } + } + } + for (const digest of wanted) { + const entry = pkg.entry(`blobs/${digest}`) + if (!entry) { + log('warn', `A file the package refers to (${digest.slice(0, 12)}…) is missing from it.`) + // -> Marked as seen either way, so a blob forty records share is reported once rather than forty + // times; the records themselves are then skipped by the server, which says which they were + staged.add(digest) + continue + } + const bytes = await readBytes(entry) + const actual = await sha256Hex(bytes) + if (actual !== digest) { + log( + 'warn', + `A file in the package does not match its checksum (${digest.slice(0, 12)}…) and was not imported.` + ) + staged.add(digest) + continue + } + await API_CLIENT.post(`import/sessions/${session.id}/blobs/${digest}`, { + body: bytes, + headers: { 'content-type': 'application/octet-stream' } + }) + staged.add(digest) + } +} + +/** + * Post a batch, halving it if the server says it is too large. + * + * The size budget above is an estimate over a format this code does not control, so it will sometimes + * be wrong — a wiki written in a script where one character is three bytes, a page carrying a base64 + * image inline. Halving converges in a handful of requests and needs no tuning, which is a better + * answer than a constant somebody has to guess right for every wiki in the world. + * + * A single record the server still refuses is skipped rather than fatal: one page too large to send + * is a page to go and look at, not a reason to abandon the other eleven thousand. It is named in the + * log, which is the only place anybody could act on it. + */ +async function postBatch(url, records, label, log) { + try { + return await API_CLIENT.post(url, { json: { records } }).json() + } catch (err) { + if (err.response?.status !== 413) { + throw err + } + if (records.length < 2) { + log('warn', `${label}: one record is too large to send and was skipped.`) + return { imported: 0, skipped: 1, warnings: [] } + } + const half = Math.ceil(records.length / 2) + const first = await postBatch(url, records.slice(0, half), label, log) + const second = await postBatch(url, records.slice(half), label, log) + return { + imported: first.imported + second.imported, + skipped: first.skipped + second.skipped, + warnings: [...(first.warnings ?? []), ...(second.warnings ?? [])] + } + } +} + +function tally(totals, result, log) { + totals.imported += result.imported ?? 0 + totals.skipped += result.skipped ?? 0 + for (const warning of result.warnings ?? []) { + log('warn', warning) + } +} + +function reportSummary(summary, log) { + const written = Object.entries(summary.progress ?? {}) + .map(([stream, count]) => `${count} ${labelFor(stream).toLowerCase()}`) + .join(', ') + // -> The one line reporting the whole run rather than a step of it, and the one somebody watching a + // long import is waiting for. `success` is what the log panel draws bold and green. + log('success', `Import finished. ${written || 'Nothing was written.'}`) + + /* + The one thing an operator has to be told about a 2.x import: a page arrives with no HTML, because + a 2.x render is 2.x's output and would be wrong here in ways nothing could later detect. The queue + that produces the real ones is a single headless browser doing a single page at a time. + */ + if (summary.pendingRenders > 0) { + log( + 'info', + `${summary.pendingRenders} pages are waiting to be rendered. They will fill in as the renderer works through them.` + ) + } + if (summary.unrenderable > 0) { + log( + 'warn', + `${summary.unrenderable} pages cannot be rendered by the server and will stay blank until somebody opens and saves them.` + ) + } +} + +function describeGenerator(manifest) { + const product = manifest.generator?.product ?? 'an unknown tool' + const version = manifest.generator?.version + return version ? `${product} ${version}` : product +} + +const LABELS = { + locales: 'Locales', + groups: 'Groups', + users: 'Users', + tree: 'Folders', + pages: 'Pages', + 'page-history': 'Page history', + assets: 'Assets', + comments: 'Comments', + navigation: 'Navigation' +} + +function labelFor(stream) { + return LABELS[stream] ?? stream +}