From 665682a93014c88081768ee1809721d18461c4f3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 13 Oct 2026 16:42:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E2=9C=A8=20add=20Content=20Co?= =?UTF-8?q?ntainers=20to=20the=20Content=20Engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers organize content into hierarchical collections - forum boards, blog sections, knowledge-base categories - without a content type per category. - `defineContentContainer({ id, fields, hierarchy, ordering, publicApi, admin })` with registration-time validation and typed metadata inferred from fields. - One core table, `core_content_containers` (adjacency list + sibling position + schema-validated `metadata`), so a definition adds no migration. - Tree service: create, update, move, reorder, delete, tree, ancestors, children and descendants. Every write takes a per-definition advisory lock and validates the whole plan first, so refused or concurrent writes cannot corrupt the tree. Deletion refuses containers with children or content. - `field.container({ definition, required })`: one container per record, an indexed RESTRICT foreign key, validated against the collection on every admin, editorial and public write. - publicWrite: the container may be chosen on create, never on update; it is resolved server-side and handed to the publication policy hook. - Public reads: container list/detail routes, and `?=` with `includeDescendants=true` on content lists. AdminCP CRUD + move routes per definition, gated by its own staff permission module. - Container admin bodies the schema refuses answer `{ code: "invalidMetadata" | "invalidInput", message }` through an optional `buildRoute` validation `hook`; namespace checks key owners by kind and id. - Boot validation across plugins, page-cache tags and `content.container.*` events. - AdminCP: a tree editor per collection at `/admin/containers/{admin.path}` with drag-and-drop reorder/move (same planner as the server, optimistic with rollback), keyboard and screen-reader support, a create/edit sheet generated from the definition's fields, and a container picker in generated content forms. - Example plugin: `example.boards` with `/boards` and `/boards/:slug`, a required `board` field on discussions, and a migration that files existing discussions under a seeded "General" board. - Docs for containers, hierarchy and frontend pages, plus field, public API, public write and event reference updates. - Tests for definitions, tree planners, the service, admin and public routes, assignment on writes and the container filter. Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 31 + .../snapshot.json | 18351 ++++++++++++++++ .../content-engine/container-frontend.mdx | 504 + .../content-engine/container-hierarchy.mdx | 171 + .../docs/content-engine/containers.mdx | 337 + .../content/docs/content-engine/events.mdx | 2 + .../content/docs/content-engine/fields.mdx | 51 +- .../web/content/docs/content-engine/index.mdx | 44 +- .../web/content/docs/content-engine/meta.json | 4 + .../docs/content-engine/plugin-files.mdx | 2 +- .../docs/content-engine/public-api.mdx | 2 +- .../docs/content-engine/public-write-api.mdx | 17 +- .../docs/content-engine/public-write.mdx | 8 +- .../content/docs/content-engine/reference.mdx | 60 +- .../docs/dev/events/built-in-events.mdx | 78 + packages/vitnode/src/api/lib/module.ts | 9 +- packages/vitnode/src/api/lib/plugin.ts | 44 +- packages/vitnode/src/api/lib/route.ts | 12 +- .../src/api/middlewares/global.middleware.ts | 23 + packages/vitnode/src/api/models/events.ts | 16 + packages/vitnode/src/blocks/capabilities.ts | 2 + .../vitnode/src/content/admin/containers.ts | 82 + .../vitnode/src/content/admin/registry.ts | 49 + packages/vitnode/src/content/admin/spec.ts | 9 + packages/vitnode/src/content/cache.ts | 3 + packages/vitnode/src/content/const.ts | 3 + .../src/content/containers/association.ts | 49 + .../vitnode/src/content/containers/const.ts | 57 + .../src/content/containers/define.test-d.ts | 93 + .../src/content/containers/define.test.ts | 365 + .../vitnode/src/content/containers/define.ts | 389 + .../vitnode/src/content/containers/errors.ts | 62 + .../vitnode/src/content/containers/guard.ts | 8 + .../src/content/containers/registry.test.ts | 193 + .../src/content/containers/registry.ts | 145 + .../containers/server/admin-routes.test.ts | 292 + .../content/containers/server/admin-routes.ts | 233 + .../containers/server/assignment.test.ts | 246 + .../content/containers/server/assignment.ts | 64 + .../containers/server/public-routes.test.ts | 187 + .../containers/server/public-routes.ts | 134 + .../content/containers/server/service.test.ts | 662 + .../src/content/containers/server/service.ts | 594 + .../src/content/containers/tree.test.ts | 402 + .../vitnode/src/content/containers/tree.ts | 315 + .../vitnode/src/content/containers/types.ts | 183 + packages/vitnode/src/content/define-fields.ts | 50 + .../src/content/define-public-write.test.ts | 30 + .../src/content/define-public-write.ts | 11 + packages/vitnode/src/content/define.test.ts | 68 + packages/vitnode/src/content/define.ts | 2 + packages/vitnode/src/content/fields.ts | 21 + packages/vitnode/src/content/index.ts | 64 + packages/vitnode/src/content/indexes.ts | 7 + packages/vitnode/src/content/schemas.ts | 14 + .../src/content/server/column-builders.ts | 15 + .../src/content/server/editorial-service.ts | 12 +- .../vitnode/src/content/server/http-errors.ts | 9 + packages/vitnode/src/content/server/index.ts | 16 + packages/vitnode/src/content/server/module.ts | 15 +- .../src/content/server/public-module.ts | 44 +- .../src/content/server/public-routes.test.ts | 153 + .../src/content/server/public-routes.ts | 48 +- .../server/public-write-routes.test.ts | 133 + .../content/server/public-write-service.ts | 51 +- packages/vitnode/src/content/server/query.ts | 24 +- .../vitnode/src/content/server/service.ts | 6 + packages/vitnode/src/content/server/table.ts | 9 + packages/vitnode/src/content/server/types.ts | 2 +- packages/vitnode/src/content/types.ts | 16 +- .../src/database/content-containers.ts | 47 + packages/vitnode/src/database/relations.ts | 2 + .../src/lib/admin-nav-source.test-d.ts | 2 +- packages/vitnode/src/lib/plugin.ts | 9 + packages/vitnode/src/locales/en.json | 104 + .../vitnode/src/pages/admin/containers.tsx | 48 + packages/vitnode/src/routes.test.ts | 8 +- packages/vitnode/src/routes.tsx | 6 + .../tanstack/admin/containers/breadcrumb.tsx | 25 + .../src/tanstack/admin/containers/route.tsx | 108 + .../src/tanstack/admin/containers/screen.tsx | 112 + .../src/tanstack/admin/content/route.tsx | 50 + .../src/tanstack/fetcher/fetcher.test-d.ts | 2 +- .../vitnode/src/tests/content-fixtures.ts | 40 + .../admin/layouts/sidebar/nav/nav-model.tsx | 52 +- .../views/containers/container-dialogs.tsx | 193 + .../containers/container-form-content.tsx | 306 + .../views/containers/container-manager.tsx | 702 + .../admin/views/containers/container-query.ts | 61 + .../views/containers/container-request.ts | 164 + .../admin/views/containers/container-row.tsx | 449 + .../views/containers/container-select.tsx | 113 + .../admin/views/containers/container-tree.ts | 252 + .../content/actions/history/revision-diff.tsx | 4 + .../admin/views/content/form/skeleton.tsx | 1 + .../views/content/lib/container-field.tsx | 144 + .../views/content/lib/field-component.tsx | 4 + .../views/admin/views/content/table/cells.tsx | 28 + plugins/example/src/admin/content.tsx | 2 + plugins/example/src/admin/nav.tsx | 8 + .../src/api/modules/admin/admin.module.ts | 2 + plugins/example/src/config.api.ts | 2 + plugins/example/src/content/boards.ts | 22 + plugins/example/src/content/discussion.ts | 9 +- plugins/example/src/locales/en.json | 66 +- plugins/example/src/pages/board-page.tsx | 300 + plugins/example/src/pages/board-search.ts | 15 + plugins/example/src/pages/board-tree.tsx | 63 + plugins/example/src/pages/boards-data.ts | 29 + plugins/example/src/pages/boards-page.tsx | 90 + plugins/example/src/pages/discussion-form.tsx | 136 +- .../example/src/pages/discussion-new-page.tsx | 81 +- plugins/example/src/pages/discussion-page.tsx | 45 +- .../example/src/pages/discussion-search.ts | 26 + plugins/example/src/routes.ts | 24 +- 115 files changed, 29206 insertions(+), 157 deletions(-) create mode 100644 apps/api/migrations/20261013103054_add_content_containers/migration.sql create mode 100644 apps/api/migrations/20261013103054_add_content_containers/snapshot.json create mode 100644 apps/web/content/docs/content-engine/container-frontend.mdx create mode 100644 apps/web/content/docs/content-engine/container-hierarchy.mdx create mode 100644 apps/web/content/docs/content-engine/containers.mdx create mode 100644 packages/vitnode/src/content/admin/containers.ts create mode 100644 packages/vitnode/src/content/containers/association.ts create mode 100644 packages/vitnode/src/content/containers/const.ts create mode 100644 packages/vitnode/src/content/containers/define.test-d.ts create mode 100644 packages/vitnode/src/content/containers/define.test.ts create mode 100644 packages/vitnode/src/content/containers/define.ts create mode 100644 packages/vitnode/src/content/containers/errors.ts create mode 100644 packages/vitnode/src/content/containers/guard.ts create mode 100644 packages/vitnode/src/content/containers/registry.test.ts create mode 100644 packages/vitnode/src/content/containers/registry.ts create mode 100644 packages/vitnode/src/content/containers/server/admin-routes.test.ts create mode 100644 packages/vitnode/src/content/containers/server/admin-routes.ts create mode 100644 packages/vitnode/src/content/containers/server/assignment.test.ts create mode 100644 packages/vitnode/src/content/containers/server/assignment.ts create mode 100644 packages/vitnode/src/content/containers/server/public-routes.test.ts create mode 100644 packages/vitnode/src/content/containers/server/public-routes.ts create mode 100644 packages/vitnode/src/content/containers/server/service.test.ts create mode 100644 packages/vitnode/src/content/containers/server/service.ts create mode 100644 packages/vitnode/src/content/containers/tree.test.ts create mode 100644 packages/vitnode/src/content/containers/tree.ts create mode 100644 packages/vitnode/src/content/containers/types.ts create mode 100644 packages/vitnode/src/database/content-containers.ts create mode 100644 packages/vitnode/src/pages/admin/containers.tsx create mode 100644 packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx create mode 100644 packages/vitnode/src/tanstack/admin/containers/route.tsx create mode 100644 packages/vitnode/src/tanstack/admin/containers/screen.tsx create mode 100644 packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx create mode 100644 packages/vitnode/src/views/admin/views/containers/container-form-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/containers/container-manager.tsx create mode 100644 packages/vitnode/src/views/admin/views/containers/container-query.ts create mode 100644 packages/vitnode/src/views/admin/views/containers/container-request.ts create mode 100644 packages/vitnode/src/views/admin/views/containers/container-row.tsx create mode 100644 packages/vitnode/src/views/admin/views/containers/container-select.tsx create mode 100644 packages/vitnode/src/views/admin/views/containers/container-tree.ts create mode 100644 packages/vitnode/src/views/admin/views/content/lib/container-field.tsx create mode 100644 plugins/example/src/content/boards.ts create mode 100644 plugins/example/src/pages/board-page.tsx create mode 100644 plugins/example/src/pages/board-search.ts create mode 100644 plugins/example/src/pages/board-tree.tsx create mode 100644 plugins/example/src/pages/boards-data.ts create mode 100644 plugins/example/src/pages/boards-page.tsx diff --git a/apps/api/migrations/20261013103054_add_content_containers/migration.sql b/apps/api/migrations/20261013103054_add_content_containers/migration.sql new file mode 100644 index 0000000000..c2987135cd --- /dev/null +++ b/apps/api/migrations/20261013103054_add_content_containers/migration.sql @@ -0,0 +1,31 @@ +CREATE TABLE "core_content_containers" ( + "id" serial PRIMARY KEY, + "pluginId" varchar(255) NOT NULL, + "definitionId" varchar(100) NOT NULL, + "parentId" integer, + "name" varchar(255) NOT NULL, + "slug" varchar(160) NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + "metadata" jsonb DEFAULT '{}' NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_containers" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "example_discussions" ADD COLUMN "board" integer;--> statement-breakpoint +-- Discussions written before boards existed are filed under a "General" board, +-- created only when there is something to file, so the column can be NOT NULL. +INSERT INTO "core_content_containers" ("pluginId", "definitionId", "name", "slug", "position") +SELECT '@vitnode/example', 'example.boards', 'General', 'general', 0 +WHERE EXISTS (SELECT 1 FROM "example_discussions");--> statement-breakpoint +UPDATE "example_discussions" SET "board" = ( + SELECT "id" FROM "core_content_containers" + WHERE "definitionId" = 'example.boards' AND "slug" = 'general' +) WHERE "board" IS NULL;--> statement-breakpoint +ALTER TABLE "example_discussions" ALTER COLUMN "board" SET NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_containers_definition_slug_key" ON "core_content_containers" ("definitionId","slug");--> statement-breakpoint +CREATE INDEX "core_content_containers_definition_parent_position_idx" ON "core_content_containers" ("definitionId","parentId","position");--> statement-breakpoint +CREATE INDEX "core_content_containers_parent_idx" ON "core_content_containers" ("parentId");--> statement-breakpoint +CREATE INDEX "example_discussions_board_published_at_idx" ON "example_discussions" ("board","publishedAt");--> statement-breakpoint +ALTER TABLE "core_content_containers" ADD CONSTRAINT "core_content_containers_q6QIucJ4HJ7B_fkey" FOREIGN KEY ("parentId") REFERENCES "core_content_containers"("id") ON DELETE RESTRICT;--> statement-breakpoint +ALTER TABLE "example_discussions" ADD CONSTRAINT "example_discussions_board_core_content_containers_id_fkey" FOREIGN KEY ("board") REFERENCES "core_content_containers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261013103054_add_content_containers/snapshot.json b/apps/api/migrations/20261013103054_add_content_containers/snapshot.json new file mode 100644 index 0000000000..bc7b7316b0 --- /dev/null +++ b/apps/api/migrations/20261013103054_add_content_containers/snapshot.json @@ -0,0 +1,18351 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "72797947-1eaf-4920-aae7-3bba9cbd49eb", + "prevIds": [ + "875ad620-968c-4b59-8795-345a5fbd5fb6" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_discussions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "board", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "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": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "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": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "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": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "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": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "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": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "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": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "board", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_board_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "board" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_discussions_board_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_discussions_pkey", + "schema": "public", + "table": "example_discussions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/content-engine/container-frontend.mdx b/apps/web/content/docs/content-engine/container-frontend.mdx new file mode 100644 index 0000000000..7fce7f05a5 --- /dev/null +++ b/apps/web/content/docs/content-engine/container-frontend.mdx @@ -0,0 +1,504 @@ +--- +title: Containers on the frontend +description: Read Content Engine containers with the typed fetcher, render a nested board tree, list the records of a container and its subcontainers with includeDescendants, and let members create content inside a container through publicWrite. +icon: AppWindow +--- + +This guide builds the member-facing side of the example plugin's boards: a `/boards` page with the nested board tree, a `/boards/:slug` page that lists the discussions of a board and every board below it, and a board picker on the "Start a discussion" form. + +The finished pages are in `plugins/example/src/pages/`, in the `board-*` and `discussion-*` files. The code below is the same, with some styling trimmed. + +## Before you begin + +You need a container collection with `publicApi`, and a content type whose `field.container()` is public, filterable and writable on create, as set up in [Containers](/docs/content-engine/containers#add-a-container-collection). The example's collection has `publicApi.path: "boards"`, so the fetcher's module is `content/boards`. + +The collection's public routes are read-only and need no session: + +| Route | Returns | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| `GET /` | `{ items }`: every container in tree order. `?parent=root` or `?parent={id}` returns one level | +| `GET /{idOrSlug}` | `{ container, ancestors, children }`: ancestors root first, direct children in order | + +Each item is `{ id, parentId, name, slug, position, depth, metadata }`, where `metadata` holds only `publicApi.fields`. An unknown container or `parent` answers `404`. A numeric segment is read as an id, anything else as a slug. + + + + + +### Add the routes + +Register the two board pages in `src/routes.ts`, and give the create page a `search` parser so it can read `?board=`. A `pendingComponent` is drawn while the loader runs, so import it directly rather than through `lazy()`: + +```ts title="plugins/example/src/routes.ts" +// [!code ++:4] +import { + FeedPendingSkeleton, + RoutePendingSkeleton, +} from "@vitnode/core/tanstack/pending"; + +import { boardSearch } from "./pages/board-search"; // [!code ++] +import { discussionNewSearch } from "./pages/discussion-search"; // [!code ++] + +export const routes = definePluginRoutes([ + // [!code ++:13] + page("/boards", { + component: lazy(() => import("./pages/boards-page")), + messages: ["@vitnode/example.boards"], + pendingComponent: RoutePendingSkeleton, + }), + + page("/boards/:slug", { + component: lazy(() => import("./pages/board-page")), + messages: ["@vitnode/example.boards"], + pendingComponent: FeedPendingSkeleton, + search: boardSearch, + }), + + page("/discussions/new", { + component: lazy(() => import("./pages/discussion-new-page")), + messages: ["@vitnode/example.discussions"], + requires: "authenticated", + search: discussionNewSearch, // [!code ++] + }), +]); +``` + +`boardSearch` keeps a valid `cursor` for paging through discussions, and `discussionNewSearch` keeps a positive `board` id. Both are small functions in `board-search.ts` and `discussion-search.ts`. The strings live under `@vitnode/example.boards` in `src/locales/en.json`. + + + + + +### Load the tree + +Create a helper that reads every board. The list comes back in tree order, flat, so one request loads the whole tree: + +```ts title="plugins/example/src/pages/boards-data.ts" +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const fetchBoards = async () => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/boards", + path: "/", + args: { query: {} }, + }); + + if (res.status !== 200) throw new Error("Loading the boards failed."); + + const { items } = await res.json(); + + return items; +}; + +export type Board = Awaited>[number]; + +const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; + +export const boardColor = (board: Pick): null | string => + typeof board.metadata.color === "string" && + HEX_COLOR.test(board.metadata.color) + ? board.metadata.color + : null; +``` + +`Board` is inferred from the API, so `board.metadata.color` is typed as `string | null` from the definition's fields, and `board.metadata.locked` is a compile error because `locked` is not public. `boardColor` accepts only a hex value before it goes into a `style` attribute, because an admin typed it. + + + + + +### Render the tree + +`contentContainerTree` from `@vitnode/core/content` nests a tree-ordered list: each node gets a `children` array. Render it recursively as nested lists inside a labelled `nav`, so screen readers announce the structure: + +```tsx title="plugins/example/src/pages/board-tree.tsx" +import type { ContentContainerTreeNode } from "@vitnode/core/content"; + +import type { Board } from "./boards-data"; + +import { boardHref } from "./board-search"; +import { boardColor } from "./boards-data"; + +export const BoardColorDot = ({ board }: { board: Board }) => { + const color = boardColor(board); + + return ( + + + + +### Add the boards page + +The route's `load` fetches and nests the tree, and the page shows it, or an empty state when no board exists yet: + +```tsx title="plugins/example/src/pages/boards-page.tsx" +import { contentContainerTree } from "@vitnode/core/content"; +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { useTranslations } from "use-intl"; + +import { BoardTree } from "./board-tree"; +import { fetchBoards } from "./boards-data"; + +const loadBoards = async () => ({ + tree: contentContainerTree(await fetchBoards()), +}); + +type BoardsPageData = Awaited>; + +export const route = definePluginRoute({ + load: loadBoards, + + head: ({ t }) => ({ + description: t("@vitnode/example.boards.desc"), + title: t("@vitnode/example.boards.title"), + }), +}); + +const BoardsPage = ({ loaderData }: PluginRoutePageProps) => { + const t = useTranslations("@vitnode/example.boards"); + + return ( +
+

+ {t("title")} +

+ + {loaderData.tree.length === 0 ? ( +

+ {t("empty.desc")} +

+ ) : ( + + )} +
+ ); +}; + +export default BoardsPage; +``` + +The example's page also has a breadcrumb, a **New discussion** button and an `Empty` state with an icon. + +
+ + + +### Add the board page + +`/boards/:slug` makes two requests. The detail route returns the board, its ancestors for the breadcrumb and its child boards. The discussions list is filtered by the board, and `includeDescendants: "true"` widens the filter to every board below it, so **Frontend** also lists what was posted in **React**: + +```tsx title="plugins/example/src/pages/board-page.tsx" +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import type { BoardSearch } from "./board-search"; + +const zodDiscussionRow = z.object({ + id: z.number(), + publishedAt: z.string().nullable(), + slug: z.string().nullable(), + title: z.string(), +}); + +const loadBoard = async (idOrSlug: string, { cursor }: BoardSearch) => { + const detail = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/boards", + path: "/{idOrSlug}", + args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } }, + }); + + if (detail.status !== 200 && detail.status !== 404) { + throw new Error("Loading the board failed."); + } + + // A 404 becomes the route's not-found page. + const { ancestors, children, container } = contentPageItem( + detail.status === 200 ? await detail.json() : null, + ); + + const filters = { + board: String(container.id), + includeDescendants: "true", + }; + + const list = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/discussions", + path: "/", + args: { + query: { ...filters, first: "20", ...(cursor ? { cursor } : {}) }, + }, + }); + + if (list.status !== 200) throw new Error("Loading the discussions failed."); + + const { edges, pageInfo } = await list.json(); + + return { + ancestors, + children, + container, + discussions: z.array(zodDiscussionRow).parse(edges), + isFirstPage: cursor === undefined, + nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, + }; +}; +``` + +- The filter parameter is named after the field, `board`, and works only because `board` is in `publicApi.filterableFields`. Without `includeDescendants`, it matches that one board. +- Filter parameters are not part of the inferred query type yet, so they are spread in from a small object instead of written as literal keys. +- An unknown board id in the filter answers `404`, not an empty list, so a stale link never looks like an empty board. +- `nextCursor` goes into `?cursor=` for an **Older discussions** link. Pass it back unchanged. + +Declare the trail as a breadcrumb group, one crumb per ancestor, and give the route a `notFound` component for the 404: + +```tsx title="plugins/example/src/pages/board-page.tsx" +export const route = definePluginRoute({ + load: async ({ params, search }) => await loadBoard(params.slug, search), + + head: ({ loaderData }) => ({ + description: loaderData?.container.metadata.description ?? undefined, + title: loaderData?.container.name, + }), + + breadcrumb: { + group: ({ loaderData }) => ( + <> + + + + + + {loaderData.ancestors.map(ancestor => ( + + + + + {ancestor.name} + + + + ))} + + + {loaderData.container.name} + + + ), + }, + + notFound: BoardNotFound, +}); +``` + +The page renders the children with the same `BoardTree`, the discussions as a list of links, and a **New discussion** link to `/discussions/new?board={container.id}`. See the full component in `board-page.tsx`. + + + + + +### Let members pick a board + +Load the same tree for the create page and turn it into select options, indented by depth so the select reads like the tree: + +```tsx title="plugins/example/src/pages/discussion-new-page.tsx" +const loadBoardOptions = async () => + (await fetchBoards()).map(board => ({ + label: `${"— ".repeat(board.depth - 1)}${board.name}`, + value: String(board.id), + })); + +export const route = definePluginRoute({ + load: loadBoardOptions, +}); +``` + +Add the board to the form schema. A select works with strings, so the ids become a `z.enum`, preselected from `?board=` when it names a real board: + +```tsx title="plugins/example/src/pages/discussion-form.tsx" +const createDiscussionCreateFormSchema = ( + t: ( + key: "board.required" | "body.required" | "title.max" | "title.min", + ) => string, + ids: [string, ...string[]], + selected: string | undefined, +) => { + const board = z.enum(ids, { error: t("board.required") }); + + return createDiscussionFormSchema(t, { + body: emptyRichTextDocument(), + title: "", + }).extend({ + board: + selected !== undefined && ids.includes(selected) + ? board.default(selected) + : board, + }); +}; +``` + +Render it with `AutoFormSelect` before the title and body fields: + +```tsx title="plugins/example/src/pages/discussion-form.tsx" +{ + id: "board", + component: props => ( + + ), +}, +``` + +On submit, send the board as a number inside `values`, like every other writable field: + +```tsx title="plugins/example/src/pages/discussion-new-page.tsx" +const onSubmit: AutoFormOnSubmit = async ( + { board, ...values }, + form, +) => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "content/discussions", + path: "/", + args: { body: { values: { ...values, board: Number(board) } } }, + }); + + if (res.status === 201) { + // Redirect to the new discussion, as in Public write frontend. + return; + } + + await rejectDiscussion(res, form); +}; +``` + +The server never trusts the id. It reads the board back inside the write's transaction and checks that it belongs to `example.boards`. A board that was deleted while the form was open, or one from another collection, answers `400` with an issue on the `board` field: + +```json +{ + "code": "CONTENT_INVALID_INPUT", + "contentTypeId": "example.discussion", + "issues": [ + { + "code": "unavailable", + "field": "board", + "message": "The selected container does not exist." + } + ] +} +``` + +`useDiscussionRejection` from [Public write frontend](/docs/content-engine/public-write-frontend#build-the-shared-form) already puts field issues under their field. Add `"board"` to its list of fields and a board-specific message for `unavailable`: + +```tsx title="plugins/example/src/pages/discussion-form.tsx" +const DISCUSSION_FIELDS = ["title", "body", "board"] as const; // [!code highlight] + +const messageOf = (code: string, field?: DiscussionField) => + t( + field === "board" && code === "unavailable" + ? "issues.board_unavailable" + : isKnownIssueCode(code) + ? ISSUE_MESSAGE_KEYS[code] + : "invalid", + ); +``` + +The edit form stays as it was. A container field cannot be in `publicWrite.update.fields`, so moving a discussion to another board is a staff edit in the AdminCP. + + + +
+ +## Check the result + +1. Create **Frontend** with **React** inside it in the AdminCP, then open `/boards`. Both boards show, **React** nested under **Frontend**. +2. Open **React** and click **New discussion**. The form opens with **— React** preselected. Post it. +3. Open `/boards/frontend`. The discussion is listed there too, because of `includeDescendants`, and the breadcrumb ends with **Boards** and **Frontend**. +4. Open `/boards/nope`. The not-found page shows. + +## Errors to handle + +| Request | Answer | What the page does | +| ------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------ | +| `GET content/boards/{idOrSlug}` for an unknown board | `404` | `contentPageItem(null)` renders the route's `notFound` | +| `GET content/boards?parent={id}` for an unknown parent | `404` | Treat it like a missing board | +| `GET content/discussions?board={id}` for an unknown board | `404` | Treat it like a missing board | +| `POST content/discussions` with a missing, deleted or foreign board | `400`, issue on `board` | Show the message under the select | +| `POST content/discussions` with no `board` at all | `400`, issue on `board` | The form's own `required` check catches it first | + +The [public write status codes](/docs/content-engine/public-write-api#status-codes) cover sign-in, permission and rate-limit answers, which work the same with or without containers. diff --git a/apps/web/content/docs/content-engine/container-hierarchy.mdx b/apps/web/content/docs/content-engine/container-hierarchy.mdx new file mode 100644 index 0000000000..aa2cc12ba1 --- /dev/null +++ b/apps/web/content/docs/content-engine/container-hierarchy.mdx @@ -0,0 +1,171 @@ +--- +title: Container hierarchy +description: How Content Engine containers nest, order, move and delete, how concurrent edits stay consistent, and how to read and change a container tree from your own API routes with createContentContainerService. +icon: ListTree +--- + +A container collection is a tree. This page explains the rules every change to that tree follows, whether it comes from the AdminCP or from your own code, and shows how to use the container service on the server. It uses the `example.boards` collection from [Containers](/docs/content-engine/containers). + +## Depth + +`depth` is 1 for a root, 2 for its children, and so on. `hierarchy.maxDepth` caps it and counts roots, so the example's `maxDepth: 3` allows **Frontend → React → Hooks** and nothing below **Hooks**. + +- The default is 5, the ceiling is 10. +- `hierarchy: { enabled: false }` keeps every container at the root: a flat, ordered list. Any `parentId` is refused. +- A move checks the whole subtree it carries. Moving a board that has children under a deep parent is refused if the deepest child would cross the limit. + +## Ordering + +Siblings are ordered by `ordering.mode`: + +| Mode | Order | Reordering | +| -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `manual` | `position`, the order staff drag them into | Allowed. `position` is zero-based among siblings and kept as `0..n-1` | +| `alphabetical` | `name`, case-insensitive | Ignored. A new or moved container goes last by position, but reads sort by name. Moving to another parent still works | + +Both modes break ties by `id`, so two reads of the same tree always agree. + +## Moves and reorders + +One operation covers both: `move(id, { parentId, position })`. `parentId: null` moves to the root. `position` is the index among the new siblings **after** the container has left its old place. So moving the first of three siblings to `position: 2` puts it last. + +A move is refused, and changes nothing, when: + +| Code | Status | When | +| ----------------- | ------ | --------------------------------------------------------------------------------------- | +| `cycle` | 400 | The new parent is the container itself or one of its descendants | +| `parentNotFound` | 400 | The new parent does not exist in this collection | +| `maxDepth` | 400 | The container or its deepest descendant would be too deep, or the hierarchy is disabled | +| `invalidPosition` | 400 | `position` is negative or past the end of the sibling list | +| `notFound` | 404 | The container does not exist | + +The old and the new sibling lists are both renumbered, so positions never have gaps. + +## Safe deletion + +Deleting never cascades. A container is deleted only when it is empty: + +- `hasChildren` (409): it still has child containers. Move or delete them first. +- `hasContent` (409): a record of any content type that uses this collection still points at it. Move or delete those records first. + +The database enforces the same rules. Both `parentId` and every `field.container()` column are foreign keys with `ON DELETE RESTRICT`, so a delete can never orphan a board or a discussion, even if another request slips in between the check and the delete. The remaining siblings are renumbered to close the gap. + +## Slugs + +A slug addresses one container of one collection, so `frontend` can exist once in `example.boards` and once in another collection. A slug you pass is normalized (lowercase ASCII, dashes) and must be free, or the write fails with `slugTaken`. A missing slug is derived from the name, with `-2`, `-3` and so on added until it is free. A slug that normalizes to nothing, such as `"!!!"`, fails with `slugEmpty`. + +## Concurrent edits + +Two admins dragging boards at the same moment cannot corrupt the tree. Every write to a collection: + +1. Takes a transaction-scoped Postgres advisory lock for that collection, so writes to one collection run one after another. Different collections never wait for each other. +2. Reads the whole tree inside the transaction, so it plans against what the previous write committed. +3. Checks every rule before changing a single row. A refused write leaves the tree exactly as it was. + +A collection holds at most 5,000 containers (`limitReached`, 409). The whole tree is read in one query, so a read costs the same at any depth and never runs one query per level. + +## Use the service on the server + +`createContentContainerService` is the one way to read and change a collection. The AdminCP routes and the public routes both use it, so your code gets the same rules, cache expiry and events. Call it inside any Hono handler with the request context: + +```ts title="plugins/example/src/api/modules/boards/archive.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { createContentContainerService } from "@vitnode/core/content/server"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { boardContainers } from "@/content/boards"; + +export const archiveBoardRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "example_boards", permission: "can_edit" }, + route: { + method: "post", + path: "/{id}/archive", + description: "Move a board under the Archive board and lock it", + request: { params: z.object({ id: z.coerce.number().int().positive() }) }, + responses: { + 200: { description: "Board archived" }, + 404: { description: "Board or Archive not found" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const boards = createContentContainerService(c, { + definition: boardContainers, + pluginId: CONFIG_PLUGIN.pluginId, + }); + + const archive = await boards.findBySlug("archive"); + if (!archive) { + throw new HTTPException(404, { + message: "Create an Archive board first.", + }); + } + + const siblings = await boards.children(archive.id); + await boards.move(id, { parentId: archive.id, position: siblings.length }); + await boards.update(id, { metadata: { locked: true } }); + + return c.json({ message: "Board archived." }, 200); + }, +}); +``` + +Refused writes throw a `ContentContainerError` with `code`, `status` (400, 404 or 409) and a readable `detail`. The generated AdminCP routes turn it into a response body of `{ code, message }`, typed by `zodContentContainerError`. In your own route, catch it or let it surface as a 500. + +| Method | Returns | Behavior | +| -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------- | +| `list()` | `Promise` | Every container, in tree order: each parent, then its subtree | +| `children(parentId)` | `Promise` | Direct children in sibling order. `null` returns the roots | +| `ancestors(id)` | `Promise` | Every container above `id`, root first. Empty for a root or an unknown id | +| `descendantIds(id)` | `Promise` | `id` plus every id below it, or `null` when `id` does not exist | +| `findById(id)` | `Promise` | One container | +| `findBySlug(slug)` | `Promise` | One container by its exact slug | +| `create(input)` | `Promise` | `{ name, slug?, parentId?, position?, metadata? }`. Goes last when `position` is left out | +| `update(id, input)` | `Promise` | `{ name?, slug?, metadata? }`. `metadata` is a partial patch, validated against the definition | +| `move(id, input)` | `Promise` | `{ parentId, position }`, as described in [Moves and reorders](#moves-and-reorders) | +| `delete(id)` | `Promise` | Deletes an empty container, as described in [Safe deletion](#safe-deletion) | +| `toPublic(record)` | `PublicRecord` | Drops every metadata field not in `publicApi.fields` | + +A record is `{ id, parentId, name, slug, position, depth, metadata, createdAt, updatedAt }`, with `metadata` typed from the definition's fields. `ContentContainerRecord` and `ContentContainerPublicRecord` from `@vitnode/core/content` name those types. + +The tree helpers the service uses are pure and browser-safe, exported from `@vitnode/core/content`: `contentContainerTree` nests a list, `orderContentContainers` sorts rows into tree order with their depth, and `contentContainerAncestors` and `contentContainerSubtreeIds` walk it. + +## Events + +Each successful write emits one event after its transaction commits. A write that is refused emits nothing, and an update that changes nothing emits nothing. + +| Event | Payload | +| --------------------------- | --------------------------------------------------------------------------- | +| `content.container.created` | `{ definitionId, pluginId, containerId, parentId }` | +| `content.container.updated` | `{ definitionId, pluginId, containerId, parentId, changedFields }` | +| `content.container.moved` | `{ definitionId, pluginId, containerId, parentId, fromParentId, position }` | +| `content.container.deleted` | `{ definitionId, pluginId, containerId, parentId }` | + +The names are shared by every collection, so filter on `definitionId`: + +```ts title="plugins/example/src/api/lib/events.ts" +import { buildEventListener } from "@vitnode/core/api/lib/events"; + +export const boardMovedListener = buildEventListener({ + event: "content.container.moved", + name: "log-board-moves", + handler: async (c, payload) => { + if (payload.definitionId !== "example.boards") return; + + await c + .get("log") + .debug( + `Board ${payload.containerId} moved from ${payload.fromParentId ?? "the root"} to ${payload.parentId ?? "the root"}`, + ); + }, +}); +``` + +`changedFields` names `name`, `slug` and the metadata fields whose value actually changed. A reorder among siblings is a `moved` event with the same `fromParentId` and `parentId`. Register listeners as described in [Events](/docs/dev/events). [Built-in events](/docs/dev/events/built-in-events#content-container-events) has the field-by-field payloads. + +Every write also expires the [page cache](/docs/content-engine/caching) entries that read the collection, including pages that list content filtered by one of its containers, so a renamed board shows its new name without a manual purge. + +Next, build pages on top of the tree in [Containers on the frontend](/docs/content-engine/container-frontend). diff --git a/apps/web/content/docs/content-engine/containers.mdx b/apps/web/content/docs/content-engine/containers.mdx new file mode 100644 index 0000000000..4dfc156d2c --- /dev/null +++ b/apps/web/content/docs/content-engine/containers.mdx @@ -0,0 +1,337 @@ +--- +title: Containers +description: Organize Content Engine records into a tree of containers, such as forum boards, blog sections or knowledge-base categories, with defineContentContainer and field.container. +icon: FolderTree +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +A container is a place a record lives in: one forum board, one blog section, one knowledge-base category. You declare a **collection** of containers once in code, admins build its tree in the AdminCP, and each record of a content type points at exactly one container through `field.container()`. + +This guide follows the example plugin: a `example.boards` collection, and discussions (`example.discussion`) filed into it. + +## Containers or a content type? + +A category could also be a content type with a [relation](/docs/content-engine/relations). Pick a container when the thing organizes content rather than being content itself: + +| | Container collection | Content type | +| ------------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | +| Declared with | `defineContentContainer` | `defineContentType` | +| Storage | One shared core table, `core_content_containers`. No migration of its own | Its own table, with a migration | +| Shape | A tree with parents, sibling order and a depth limit | Flat records | +| Fields | A few scalars (`text`, `textarea`, `number`, `boolean`, `enum`) | Every field kind | +| Drafts, revisions | No. A container exists or it does not | Optional `publication`, `editorial` and friends | +| Linked from records | `field.container()`, at most one per content type | `field.relation()`, as many as you need | +| Public filter | `?board=4`, and `includeDescendants=true` for a whole subtree | `?category=4`, exact match only | + +A record belongs to one container. For anything else it points at, such as tags, keep using relations. + +## Add a container collection + +You need a content type to file into containers, like `example.discussion` from [Public write](/docs/content-engine/public-write#enable-public-writing). + + + + + +### Define the collection + +Create `src/content/boards.ts`. The definition describes the collection; each board is a row an admin creates later: + +```ts title="plugins/example/src/content/boards.ts" +import { defineContentContainer, field } from "@vitnode/core/content"; + +export const boardContainers = defineContentContainer({ + id: "example.boards", + + fields: { + description: field.textarea({ maxLength: 500, nullable: true }), + color: field.text({ maxLength: 7, nullable: true }), + icon: field.text({ maxLength: 64, nullable: true }), + locked: field.boolean({ defaultValue: false }), + }, + + hierarchy: { maxDepth: 3 }, + ordering: { mode: "manual" }, + + publicApi: { + path: "boards", + fields: ["description", "color", "icon"], + }, + + admin: { path: "example/boards" }, +}); +``` + +Every container already has `id`, `parentId`, `name`, `slug`, `position` and `depth`. `fields` adds your own values, stored in a `metadata` column and validated against the definition on every write. `locked` is not in `publicApi.fields`, so it never leaves the server. + +| Name | Type | Required | Default | Description | +| ----------- | ------------------------------------------- | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------- | +| `id` | `string` | Yes | | `plugin.collection`, lowercase and dot separated. Every row stores it, so never rename it | +| `fields` | `field.*()` map | No | `{}` | Extra values per container. `text`, `textarea`, `number`, `boolean` and `enum` only | +| `hierarchy` | `{ enabled?: boolean; maxDepth?: number }` | No | `{ enabled: true, maxDepth: 5 }` | `maxDepth` counts levels, roots included, from 2 to 10. `enabled: false` makes a flat list | +| `ordering` | `{ mode?: "manual" \| "alphabetical" }` | No | `{ mode: "manual" }` | How siblings are sorted. See [Hierarchy and ordering](/docs/content-engine/container-hierarchy) | +| `publicApi` | `{ path: string; fields?: string[] }` | No | off | Read-only public routes at `/api/{pluginId}/content/{path}` | +| `admin` | `{ path?; permissionModule?; navigation? }` | No | see below | The AdminCP screen and its staff permissions | + +`admin.path` defaults to the id with dots turned into slashes (`example/boards`), so the screen opens at `/admin/containers/example/boards`. `admin.permissionModule` defaults to the snake-cased id (`example_boards`), and `admin.navigation: false` hides the sidebar entry. + + + + + +### Point the content type at it + +Add one `field.container()` to the content type. List it in `publicApi.fields` to return it, in `filterableFields` to filter by it, and in `publicWrite.create.fields` to let members pick it: + +```ts title="plugins/example/src/content/discussion.ts" +import { boardContainers } from "@/content/boards"; // [!code ++] + +export const discussionContentType = defineContentType({ + id: "example.discussion", + tableName: "example_discussions", + + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + body: field.richText({ required: true }), + author: field.user(), + board: field.container({ definition: boardContainers, required: true }), // [!code ++] + }, + + publicApi: { + path: "discussions", + fields: ["id", "title", "slug", "body", "board", "publishedAt"], // [!code highlight] + filterableFields: ["board"], // [!code ++] + }, + + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["title", "body", "board"], status: "published" }, // [!code highlight] + update: { fields: ["title", "body"] }, + }, +}); +``` + +The column is an `integer` foreign key to `core_content_containers.id` with `ON DELETE RESTRICT`, plus an index on `(board, publishedAt)`. Every write checks that the id belongs to `example.boards`, not just that it exists. All options are in [Fields](/docs/content-engine/fields#container). + + + + + +### Register the collection on the API + +Pass the definition to both content modules. The admin module adds the staff routes and the `example_boards` permissions; the public module adds the read routes: + +```ts title="plugins/example/src/api/modules/admin/admin.module.ts" +import { boardContainers } from "@/content/boards"; // [!code ++] + +buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + containers: [boardContainers], // [!code ++] + contentTypes: [articleContent, discussionContent], +}), +``` + +```ts title="plugins/example/src/config.api.ts" +buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + containers: [boardContainers], // [!code ++] + contentTypes: [articleContent, discussionContent], +}), +``` + +A content type whose `field.container()` names a collection no plugin registers stops the API at startup. Registering the same definition object in both modules is fine. + + + + + +### Register the AdminCP screen + +Add the collection to `containers` in both AdminCP files, next to your content types. `icon` is optional and defaults to a folder icon: + +```tsx title="plugins/example/src/admin/nav.tsx" +import { FolderTreeIcon } from "lucide-react"; // [!code ++] + +import { boardContainers } from "@/content/boards"; // [!code ++] + +// [!code ++:4] +export const exampleBoardsNav = { + definition: boardContainers, + icon: , +}; + +export const adminNav = { + pluginId: CONFIG_PLUGIN.pluginId, + containers: [exampleBoardsNav], // [!code ++] + contentTypes: [exampleArticleNav, exampleDiscussionNav], +} satisfies AdminNavPluginSource; +``` + +```tsx title="plugins/example/src/admin/content.tsx" +export const adminContent = { + pluginId: CONFIG_PLUGIN.pluginId, + containers: [exampleBoardsNav], // [!code ++] + contentTypes: [exampleArticleNav, exampleDiscussionNav], +} satisfies ContentFrontendPluginSource; +``` + +[Plugin files](/docs/content-engine/plugin-files) explains why the two files are separate. + + + + + +### Add the labels + +The AdminCP reads the collection's names from `{pluginId}.containers.{entity}`, where the entity is the id without its plugin prefix. The permission labels follow the permission module: + +```json title="plugins/example/src/locales/en.json" +{ + "@vitnode/example": { + "containers": { + "boards": { + "label": "{count, plural, one {Board} other {Boards}}", + "title": "Boards", + "desc": "The board tree members file their discussions into.", + "fields": { + "description": "Description", + "color": "Color", + "icon": "Icon", + "locked": "Locked" + } + } + }, + "content": { + "discussion": { "fields": { "board": "Board" } } + } + }, + "@vitnode/example:example_boards": "Boards", + "@vitnode/example:example_boards:can_view": "View boards", + "@vitnode/example:example_boards:can_create": "Create boards", + "@vitnode/example:example_boards:can_edit": "Edit and move boards", + "@vitnode/example:example_boards:can_delete": "Delete boards" +} +``` + +Without labels, the screen falls back to a name derived from the id, such as "Boards". + + + + + +### Build, migrate and restart + +Run this from the workspace root, then restart `pnpm dev`: + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +The collection itself needs no table: every definition shares `core_content_containers`. The migration only adds the `board` column to `example_discussions`. + + + A required container field becomes a `NOT NULL` column. If the table already + has rows, edit the generated migration to create a container and assign it + before the `SET NOT NULL`, as the example's migration does with a "General" + board. [Database](/docs/content-engine/database) covers editing migrations. + + + + + + +## Manage containers in the AdminCP + +Open **AdminCP → Example → Boards** (`/admin/containers/example/boards`). Staff need the collection's `can_view` permission to see it, and `can_create`, `can_edit` or `can_delete` for each action. + +- **The tree** shows every board with its children nested below it. Drag a board to reorder it among its siblings or to drop it inside another board. +- **Create and edit** open a sheet with the name, the slug and the collection's own fields. A container created without a slug gets one derived from its name. +- **The move menu** on each row does the same as dragging from the keyboard: **Move up**, **Move down**, **Move to top level** and **Move inside…**. +- **Delete** works only on an empty board. A board with child boards or with discussions is refused with a message that says which one, and nothing changes. + +In a content type's generated form, a container field becomes a **picker** that lists the collection's tree, so staff can file a discussion into a board or move it to another one. + +A drag that breaks a rule, such as nesting a board deeper than `maxDepth` or dropping it inside its own child, is refused the same way. [Hierarchy and ordering](/docs/content-engine/container-hierarchy) lists every rule. + +## Check the result + +Create a "Frontend" board with a "React" board inside it, then read the public tree: + +```bash +curl http://localhost:3000/api/@vitnode/example/content/boards +``` + +```json +{ + "items": [ + { + "id": 1, + "parentId": null, + "name": "Frontend", + "slug": "frontend", + "position": 0, + "depth": 1, + "metadata": { + "description": "Everything in the browser", + "color": "#2563eb", + "icon": null + } + }, + { + "id": 2, + "parentId": 1, + "name": "React", + "slug": "react", + "position": 0, + "depth": 2, + "metadata": { "description": null, "color": null, "icon": null } + } + ] +} +``` + +Items come in tree order: each parent, then its subtree. `metadata` holds only `publicApi.fields`, and a value nobody set is `null`. [Containers on the frontend](/docs/content-engine/container-frontend) turns this into pages. + +## Definition errors + +`defineContentContainer` and the boot checks throw a `ContentEngineError` before the app serves a request: + +| Cause | Message starts with | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| An id that is not `plugin.collection` | `Container definition id "x" must look like "plugin.collection"` | +| A field named `id`, `name`, `slug`, `depth`, `metadata` or another column | `Field "x" is reserved.` | +| A field kind other than text, textarea, number, boolean or enum | `Field "x" is a "relation" field.` | +| A field with `localized`, `unique` or `ai` | `Field "x" is` followed by the option and why containers refuse it | +| `maxDepth` below 2 or above 10 | `hierarchy.maxDepth must be a whole number from 2 to 10.` | +| `publicApi.fields` naming an unknown or built-in field | `publicApi.fields references unknown field "x".` | +| Two collections with one id or one `admin.path` | `Duplicate container definition id` / `AdminCP path "x" is claimed by both` | +| A collection and a content type sharing a public path or permission module | `Public path "x" is claimed by both` / `Permission module "x" is used by both` | +| Two `field.container()` in one content type | `Content type declares 2 container fields` | +| `field.container()` pointing at an unregistered collection | `Field "x" uses the container collection "y", which no plugin registers.` | +| A container field in `publicWrite.update.fields` | `publicWrite.update.fields includes the container field "x".` | + +## What's next + + + Per-container policies are planned, not shipped. Today every container behaves + the same way. + + +Containers are where per-place rules will live: a publishing mode per board (go live or wait as a draft), moderator approval, who may post or read in a board, and boards hidden from guests. The groundwork is in place. A public create already resolves the container on the server and hands it to `resolveContentPublicationPolicy`, so these policies can arrive without changing your routes or forms. + +- [Hierarchy and ordering](/docs/content-engine/container-hierarchy): depth, moves, safe deletion and the server-side service. +- [Containers on the frontend](/docs/content-engine/container-frontend): a board tree, a board page and a create form with a board picker. diff --git a/apps/web/content/docs/content-engine/events.mdx b/apps/web/content/docs/content-engine/events.mdx index d7f2efea66..90565db250 100644 --- a/apps/web/content/docs/content-engine/events.mdx +++ b/apps/web/content/docs/content-engine/events.mdx @@ -45,6 +45,8 @@ A few payload details that change what a listener does: - `scheduledBy` and `scheduleId` are set only when a schedule fired the change. `actorUserId` on `scheduled` is the same person as `scheduledBy` later. - The delivery events arrive alongside `updated`, `restored` or a publication event, never instead of them. `locale` is `null` for a shared slug. +[Container collections](/docs/content-engine/containers) emit their own four events, `content.container.created`, `updated`, `moved` and `deleted`, listed in [Container hierarchy](/docs/content-engine/container-hierarchy#events). + The listener also receives an envelope with the actor, the time and `pluginId`. `pluginId` is always the plugin that owns the content type, even when a core queue task ran the change. Field-by-field descriptions are in [Built-in events](/docs/dev/events/built-in-events#content-engine-events). ## Subscribe to an event diff --git a/apps/web/content/docs/content-engine/fields.mdx b/apps/web/content/docs/content-engine/fields.mdx index bc19a626e8..7eace13be0 100644 --- a/apps/web/content/docs/content-engine/fields.mdx +++ b/apps/web/content/docs/content-engine/fields.mdx @@ -46,22 +46,23 @@ The second half holds the references: a user picker, two file dropzones and a re ## Field helpers -| Helper | Postgres column | AdminCP control | Can be `localized` | -| -------------------- | -------------------------------------------- | ---------------------------------------- | ------------------ | -| `field.text()` | `varchar(maxLength)`, 255 by default | Text input | Yes | -| `field.textarea()` | `text` | Textarea | Yes | -| `field.richText()` | `jsonb` | Rich text editor | Yes | -| `field.number()` | `integer` or `double precision` | Number input with steppers | No | -| `field.boolean()` | `boolean` | Switch | No | -| `field.enum()` | `varchar(length)`, 64 by default | Select or radio buttons | No | -| `field.dateTime()` | `timestamp` | Date and time picker | No | -| `field.slug()` | `varchar(maxLength)`, 160 by default, unique | Text input | Yes | -| `field.user()` | `integer` referencing `core_users.id` | User picker | No | -| `field.file()` | `integer` referencing `core_files.id` | File dropzone | No | -| `field.relation()` | The target's key type, referencing its table | Search picker | No | -| `field.group()` | One column per leaf | A fieldset with the leaf controls | Yes, as a whole | -| `field.repeatable()` | A child table | A list of rows you can add, move, remove | No | -| `field.blocks()` | `jsonb`, `NOT NULL DEFAULT '[]'` | Not in the form; edited as widgets | Yes | +| Helper | Postgres column | AdminCP control | Can be `localized` | +| -------------------- | -------------------------------------------------- | ---------------------------------------- | ------------------ | +| `field.text()` | `varchar(maxLength)`, 255 by default | Text input | Yes | +| `field.textarea()` | `text` | Textarea | Yes | +| `field.richText()` | `jsonb` | Rich text editor | Yes | +| `field.number()` | `integer` or `double precision` | Number input with steppers | No | +| `field.boolean()` | `boolean` | Switch | No | +| `field.enum()` | `varchar(length)`, 64 by default | Select or radio buttons | No | +| `field.dateTime()` | `timestamp` | Date and time picker | No | +| `field.slug()` | `varchar(maxLength)`, 160 by default, unique | Text input | Yes | +| `field.user()` | `integer` referencing `core_users.id` | User picker | No | +| `field.file()` | `integer` referencing `core_files.id` | File dropzone | No | +| `field.relation()` | The target's key type, referencing its table | Search picker | No | +| `field.container()` | `integer` referencing `core_content_containers.id` | Container tree picker | No | +| `field.group()` | One column per leaf | A fieldset with the leaf controls | Yes, as a whole | +| `field.repeatable()` | A child table | A list of rows you can add, move, remove | No | +| `field.blocks()` | `jsonb`, `NOT NULL DEFAULT '[]'` | Not in the form; edited as widgets | Yes | `user`, `file` and `relation` with `multiple: true` store their values in a junction table instead of a column. Localized fields live in the translation table. See [Translations](/docs/content-engine/localization). @@ -230,6 +231,24 @@ category: field.relation({ required: true, onDelete: "restrict", target: () => c A single relation must be `required` or `nullable`. A to-many relation can be neither. The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. +## Container + +```ts +board: field.container({ definition: boardContainers, required: true }), +``` + +| Name | Type | Required | Default | Description | +| ------------- | --------------------------------- | -------- | ------- | ------------------------------------------------------------------ | +| `definition` | `defineContentContainer()` result | Yes | | The collection the record's container comes from | +| `required` | `boolean` | No | `false` | Every record needs a container. Without it, a record may have none | +| `description` | `string` | No | | Text or an i18n key shown under the picker in the AdminCP | + +`field.container()` files a record into one container of a [container collection](/docs/content-engine/containers), such as a discussion into a forum board. `nullable` is always the opposite of `required`, so there is no `nullable` option, no default and no `localized`. A content type can have at most one container field, and its collection must be registered with `buildContentAdminModule({ containers })`. + +The column is an `ON DELETE RESTRICT` foreign key with an index on the field (plus `publishedAt` when `publication` is on), so a container that still holds records cannot be deleted. Every write checks that the id belongs to the field's collection and answers `400` with `CONTAINER_NOT_FOUND` or `CONTAINER_DEFINITION_MISMATCH` otherwise. + +The public API returns the container as its id. In `publicApi.filterableFields`, it filters a list with `?board=4`, and `includeDescendants=true` widens that to the whole subtree. In `publicWrite`, it may be listed in `create.fields` but not in `update.fields`. [Containers on the frontend](/docs/content-engine/container-frontend) shows both. + ## Group ```ts diff --git a/apps/web/content/docs/content-engine/index.mdx b/apps/web/content/docs/content-engine/index.mdx index 7f2c1a263d..963484194b 100644 --- a/apps/web/content/docs/content-engine/index.mdx +++ b/apps/web/content/docs/content-engine/index.mdx @@ -60,27 +60,29 @@ Content types live in code and in your migrations, not in the database. There is ## Pick a guide -| You want to | Guide | -| -------------------------------------------------------- | ---------------------------------------------------------------- | -| Build your first content type, with its AdminCP screen | [First content type](/docs/content-engine/create-a-content-type) | -| Add drafts and a read-only public API | [Public API](/docs/content-engine/public-api) | -| Understand how public reads are cached and refreshed | [Caching](/docs/content-engine/caching) | -| Give each record its own page with SEO metadata | [Public pages](/docs/content-engine/public-pages) | -| Control titles, descriptions, noindex and the sitemap | [SEO](/docs/content-engine/seo) | -| Make records findable in the site search | [Search](/docs/content-engine/search) | -| Link records, group fields or repeat rows | [Relations](/docs/content-engine/relations) | -| Translate content into several languages | [Translations](/docs/content-engine/localization) | -| Store and render formatted text | [Rich text](/docs/content-engine/rich-text) | -| Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) | -| Let several editors work on one record at the same time | [Live editing](/docs/content-engine/live-editing) | -| Take a record off the public site without unpublishing | [Hide content](/docs/content-engine/content-visibility) | -| Let editors copy a record as a new draft | [Duplicate content](/docs/content-engine/duplicate-content) | -| Key records by UUID or bigint instead of serial integers | [Record ID strategies](/docs/content-engine/id-strategy) | -| Change list columns, form sections or field components | [AdminCP](/docs/content-engine/admincp) | -| Read and write content from your own API routes | [Services](/docs/content-engine/services) | -| React to created, updated or published records | [Events](/docs/content-engine/events) | -| Understand the generated tables and ship a schema change | [Database](/docs/content-engine/database) | -| Check a content type before it goes live | [Production](/docs/content-engine/production) | +| You want to | Guide | +| -------------------------------------------------------- | --------------------------------------------------------------------- | +| Build your first content type, with its AdminCP screen | [First content type](/docs/content-engine/create-a-content-type) | +| Add drafts and a read-only public API | [Public API](/docs/content-engine/public-api) | +| Understand how public reads are cached and refreshed | [Caching](/docs/content-engine/caching) | +| Give each record its own page with SEO metadata | [Public pages](/docs/content-engine/public-pages) | +| Control titles, descriptions, noindex and the sitemap | [SEO](/docs/content-engine/seo) | +| Make records findable in the site search | [Search](/docs/content-engine/search) | +| Link records, group fields or repeat rows | [Relations](/docs/content-engine/relations) | +| Organize records into boards, sections or categories | [Containers](/docs/content-engine/containers) | +| Show a container tree and its content on the site | [Containers on the frontend](/docs/content-engine/container-frontend) | +| Translate content into several languages | [Translations](/docs/content-engine/localization) | +| Store and render formatted text | [Rich text](/docs/content-engine/rich-text) | +| Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) | +| Let several editors work on one record at the same time | [Live editing](/docs/content-engine/live-editing) | +| Take a record off the public site without unpublishing | [Hide content](/docs/content-engine/content-visibility) | +| Let editors copy a record as a new draft | [Duplicate content](/docs/content-engine/duplicate-content) | +| Key records by UUID or bigint instead of serial integers | [Record ID strategies](/docs/content-engine/id-strategy) | +| Change list columns, form sections or field components | [AdminCP](/docs/content-engine/admincp) | +| Read and write content from your own API routes | [Services](/docs/content-engine/services) | +| React to created, updated or published records | [Events](/docs/content-engine/events) | +| Understand the generated tables and ship a schema change | [Database](/docs/content-engine/database) | +| Check a content type before it goes live | [Production](/docs/content-engine/production) | ## Reference diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 9a50620b5b..ee083c8d49 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -20,6 +20,10 @@ "localization", "rich-text", "id-strategy", + "---Organize content---", + "containers", + "container-hierarchy", + "container-frontend", "---Editorial---", "editorial", "live-editing", diff --git a/apps/web/content/docs/content-engine/plugin-files.mdx b/apps/web/content/docs/content-engine/plugin-files.mdx index 5ce5610764..3bff30e051 100644 --- a/apps/web/content/docs/content-engine/plugin-files.mdx +++ b/apps/web/content/docs/content-engine/plugin-files.mdx @@ -14,7 +14,7 @@ VitNode reads a plugin's content types from several small files instead of one b | `src/database/.ts` | Every table | Drizzle Kit, from `dist/src/database/*.js` | `createContentModel` and the generated tables. Server only | | `src/api/modules/admin/admin.module.ts` | Any name | `src/config.api.ts` | `buildContentAdminModule` inside a module named `admin` | | `src/config.api.ts` | The API plugin, `VitNodeApiPlugin` | The API, and the app's typed `fetcher` | The admin module, and `buildContentPublicModule` for public routes | -| `src/admin/nav.tsx` | `adminNav` | The AdminCP layout, on every AdminCP page | `{ definition, icon }` per content type | +| `src/admin/nav.tsx` | `adminNav` | The AdminCP layout, on every AdminCP page | `{ definition, icon }` per content type and container collection | | `src/admin/content.tsx` | `adminContent` | The content screens, loaded on demand | Screen registrations, custom cells, fields and form layouts | | `src/content.ts` | `contentTypes` | The web build and the API at startup | Every content type with `delivery` or `search` | | `src/routes.ts` | `routes` | The web build | The pages that serve `delivery` and `search` URLs | diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx index 46176d72d5..e933f76a2e 100644 --- a/apps/web/content/docs/content-engine/public-api.mdx +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -251,7 +251,7 @@ All routes are `GET` and live under `/api/{pluginId}/content/{publicApi.path}`. | `/delivery/item/{id}` | Canonical URL and SEO metadata for one record, only with delivery | | `/delivery/sitemap` | Sitemap entries, only with `delivery.sitemap` | -The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). +The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A filterable [container field](/docs/content-engine/fields#container) also accepts `includeDescendants=true`, so `?board=4&includeDescendants=true` lists board 4 and every board below it; an unknown container answers `404`. See [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page). A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). Next, give each article a web page with [Public pages](/docs/content-engine/public-pages), or see how these reads are cached in [Caching](/docs/content-engine/caching). diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx index 8887a02d2f..9e63bd67a7 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -40,7 +40,7 @@ The read and write allowlists are independent. A field can be writable without b `create.fields` and `update.fields` accept top-level, shared fields of these kinds, from `CONTENT_PUBLIC_WRITABLE_KINDS`: -`boolean`, `dateTime`, `enum`, `number`, `relation`, `richText`, `slug`, `text`, `textarea` +`boolean`, `container`, `dateTime`, `enum`, `number`, `relation`, `richText`, `slug`, `text`, `textarea` They never accept: @@ -57,6 +57,8 @@ They never accept: A `relation` is writable only when its target content type has a `publicApi`. On each request, every referenced record must also be published and not hidden. +A `container` is writable in `create.fields` only. A member picks the container once, when they create the record; moving it later is a staff edit, so an author cannot drop their record into a container they could not post in. On each create, the server reads the container back inside the write's transaction and checks that it belongs to the field's collection. A missing or foreign container answers `400` with an `unavailable` issue on that field. See [Containers on the frontend](/docs/content-engine/container-frontend#let-members-pick-a-board). + ### Moderator permission A content type with `update` adds `can_edit` to its plugin's moderator permissions, under the content type's permission module, such as `discussion`. It shares the `@vitnode/example:discussion:can_edit` label with the admin permission. If the plugin declares that module under `permissionStaff.moderator` itself, the declared list wins. @@ -406,12 +408,12 @@ A route outside `/content/` only gets the global rate limit, which is why this o ### Helpers -| Function | Returns | Behavior | -| ------------------------------------------------------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `resolveContentPublicWriteActor(c)` | `{ type: "api", userId }` \| `null` | The signed-in user of the public session. Never an AdminCP session, never the body | -| `authorizeContentPublicWrite(c, { action, actor, definition, pluginId, row })` | `{ allowed, via: "owner" \| "moderator" \| null }` | The owner and moderator rules above, for a stored `row`. `action` is `"update"` | -| `contentPublicWriteRefusal(row)` | `"forbidden"` \| `"notFound"` | `"forbidden"` only when the public can already read `row` | -| `resolveContentPublicationPolicy({ actor, definition, operation })` | `"draft"` \| `"published"` | The status a public create starts with. Today it returns `publicWrite.create.status` | +| Function | Returns | Behavior | +| ------------------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `resolveContentPublicWriteActor(c)` | `{ type: "api", userId }` \| `null` | The signed-in user of the public session. Never an AdminCP session, never the body | +| `authorizeContentPublicWrite(c, { action, actor, definition, pluginId, row })` | `{ allowed, via: "owner" \| "moderator" \| null }` | The owner and moderator rules above, for a stored `row`. `action` is `"update"` | +| `contentPublicWriteRefusal(row)` | `"forbidden"` \| `"notFound"` | `"forbidden"` only when the public can already read `row` | +| `resolveContentPublicationPolicy({ actor, container, definition, operation })` | `"draft"` \| `"published"` | The status a public create starts with. Today it returns `publicWrite.create.status`. `container` is the resolved container, or `null` | ## Definition errors @@ -441,6 +443,7 @@ A route outside `/content/` only gets the global rate limit, which is why this o | A slug with a `source` | `publicWrite.create.fields includes the slug "slug", which is derived from "title"` | | A relation whose target has no `publicApi` | `publicWrite.create.fields includes the relation "x", but its target "y" has no public API.` | | A required field missing from `create.fields` | `publicWrite.create.fields leaves out "x", which every new record needs.` | +| A container field in `update.fields` | `publicWrite.update.fields includes the container field "x".` | | `create.status` is not a status | `publicWrite.create.status is "x". It is one of: draft, published.` | | `create.captcha` or `update.own` is not a boolean | ``publicWrite.create.captcha is `true` or `false`.`` | diff --git a/apps/web/content/docs/content-engine/public-write.mdx b/apps/web/content/docs/content-engine/public-write.mdx index 808d97d1fb..f81a7eb76c 100644 --- a/apps/web/content/docs/content-engine/public-write.mdx +++ b/apps/web/content/docs/content-engine/public-write.mdx @@ -300,13 +300,17 @@ Public writes trigger the same side effects as AdminCP writes, so nothing new ne - [Search](/docs/content-engine/search) is synced after the transaction commits, so a discussion published on create is searchable straight away. - On an [editorial](/docs/content-engine/editorial#revisions) content type, each create and edit stores a revision attributed to the signed-in user, and staff can restore it from the AdminCP. -## Future container policies +## Containers + +A content type organized into [containers](/docs/content-engine/containers), such as discussions in forum boards, lets members pick the container when they create a record: list the `field.container()` in `create.fields`. It cannot be in `update.fields`, so moving a record to another container stays a staff edit. The server resolves the container inside the write's transaction and refuses a missing or foreign one with a `400` on that field. + +### Future container policies Per-container publication policies are planned, not shipped. Today every public create uses `publicWrite.create.status`. -Whether a new record starts as a draft or goes live is decided in one server-side function, `resolveContentPublicationPolicy`, and never by the payload. It already receives the signed-in user and the operation, so a later Content Containers feature can let each container choose its own policy, such as "publish immediately" in one forum and "wait for a moderator's approval" in another, without changing the routes or your forms. +Whether a new record starts as a draft or goes live is decided in one server-side function, `resolveContentPublicationPolicy`, and never by the payload. It already receives the signed-in user, the operation and the resolved container, so each container will be able to choose its own policy, such as "publish immediately" in one board and "wait for a moderator's approval" in another, without changing the routes or your forms. Next, build the create and edit pages in [Public write frontend](/docs/content-engine/public-write-frontend). diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index 1daa6f1d81..a3f89a1c2b 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -50,25 +50,25 @@ Many of these rules are type errors too, so your editor often complains before t ## Top-level options -| Name | Type | Required | Default | Description | -| -------------- | --------------------------------------- | -------- | ---------- | ------------------------------------------------------------------------------- | -| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) | -| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` | -| `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) | -| `idStrategy` | `"serial"` \| `"uuid"` \| `"bigint"` | No | `"serial"` | Type of the primary key. See [`idStrategy`](#idstrategy) | -| `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title | -| `publication` | `boolean` | No | off | Draft and published states | -| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes | -| `publicWrite` | [`publicWrite`](#publicwrite) | No | off | Create and edit routes for signed-in users. Needs `publicApi` | -| `delivery` | `true` \| [`delivery`](#delivery) | No | off | Public page URLs, SEO metadata, redirects and sitemap | -| `editorial` | `true` \| [`editorial`](#editorial) | No | off | Revisions, preview links and scheduling | -| `liveEditing` | `boolean` | No | `false` | [Live editing](#liveediting) in the AdminCP. Needs `editorial` | -| `localization` | [`localization`](#localization) | No | off | A translation table for `localized: true` fields | -| `search` | [`search`](#search) | No | off | Site search indexing | -| `visibility` | `boolean` | No | off | [Hide and unhide](#visibility) records. Needs `publication` | -| `duplication` | `true` \| [`duplication`](#duplication) | No | off | A [Duplicate](#duplication) action that copies a record into a new draft | +| Name | Type | Required | Default | Description | +| -------------- | --------------------------------------- | -------- | ---------- | --------------------------------------------------------------------------------- | +| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) | +| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` | +| `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) | +| `idStrategy` | `"serial"` \| `"uuid"` \| `"bigint"` | No | `"serial"` | Type of the primary key. See [`idStrategy`](#idstrategy) | +| `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title | +| `publication` | `boolean` | No | off | Draft and published states | +| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes | +| `publicWrite` | [`publicWrite`](#publicwrite) | No | off | Create and edit routes for signed-in users. Needs `publicApi` | +| `delivery` | `true` \| [`delivery`](#delivery) | No | off | Public page URLs, SEO metadata, redirects and sitemap | +| `editorial` | `true` \| [`editorial`](#editorial) | No | off | Revisions, preview links and scheduling | +| `liveEditing` | `boolean` | No | `false` | [Live editing](#liveediting) in the AdminCP. Needs `editorial` | +| `localization` | [`localization`](#localization) | No | off | A translation table for `localized: true` fields | +| `search` | [`search`](#search) | No | off | Site search indexing | +| `visibility` | `boolean` | No | off | [Hide and unhide](#visibility) records. Needs `publication` | +| `duplication` | `true` \| [`duplication`](#duplication) | No | off | A [Duplicate](#duplication) action that copies a record into a new draft | | `reports` | `true` \| [`reports`](#reports) | No | off | Readers can [report](#reports) published records to moderators. Needs `publicApi` | -| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | +| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | A feature block is on when it is present. `publicApi`, `publicWrite`, `localization` and `search` take an options object. `publication`, `liveEditing` and `visibility` are `true`, and `editorial`, `delivery` and `duplication` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist. @@ -143,19 +143,19 @@ publication: true, Generates read-only routes at `/api/{pluginId}/content/{path}` that return published records only. Guide: [Publish content with a public API](/docs/content-engine/public-api). -| Name | Type | Required | Default | Description | -| ------------------ | ------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------ | -| `path` | `string` | Yes | | One URL segment matching `/^[a-z][a-z0-9-]*$/`, at most 64 characters, not `admin` | -| `fields` | field names | Yes | | The allowlist, with no wildcard. Must contain exactly one top-level slug field. No duplicates | -| `searchableFields` | field names | No | `[]` | Matched by `?search=`. Text, textarea, richText or slug fields. Rich text matches its words only | -| `filterableFields` | field names | No | `[]` | Each becomes an equality query parameter. Boolean, enum, number, relation, slug or text fields | -| `orderableFields` | field names | No | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added | -| `defaultOrderBy` | field name | No | `"publishedAt"` | Must be orderable | -| `defaultOrder` | `"asc"` \| `"desc"` | No | `"desc"` | | +| Name | Type | Required | Default | Description | +| ------------------ | ------------------- | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | `string` | Yes | | One URL segment matching `/^[a-z][a-z0-9-]*$/`, at most 64 characters, not `admin` | +| `fields` | field names | Yes | | The allowlist, with no wildcard. Must contain exactly one top-level slug field. No duplicates | +| `searchableFields` | field names | No | `[]` | Matched by `?search=`. Text, textarea, richText or slug fields. Rich text matches its words only | +| `filterableFields` | field names | No | `[]` | Each becomes an equality query parameter. Boolean, container, enum, number, relation, slug or text fields. A container also takes `includeDescendants=true` | +| `orderableFields` | field names | No | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added | +| `defaultOrderBy` | field name | No | `"publishedAt"` | Must be orderable | +| `defaultOrder` | `"asc"` \| `"desc"` | No | `"desc"` | | What `fields` accepts: -- Fields of kind text, textarea, richText, slug, number, boolean, enum, dateTime, file, relation and blocks. A file is returned as its public descriptor, never as the file id. +- Fields of kind text, textarea, richText, slug, number, boolean, enum, dateTime, file, relation, container and blocks. A file is returned as its public descriptor, never as the file id. A container is returned as its id. - The columns `id`, `createdAt`, `updatedAt` and `publishedAt`. - Group and repeatable leaves one at a time, as `"seo.title"`. Naming the whole group throws, so a leaf added later stays private. - Not `status` (every public row is published), and not `field.user()`, so an author is never published by accident. @@ -179,11 +179,13 @@ Adds create and edit routes for signed-in users beside the `publicApi` routes, w ```ts title="plugins/example/src/content/discussion.ts" publicWrite: { ownership: { field: "author" }, - create: { fields: ["title", "body"], status: "published" }, + create: { fields: ["title", "body", "board"], status: "published" }, update: { fields: ["title", "body"] }, }, ``` +A [container field](/docs/content-engine/fields#container), such as `board`, may be in `create.fields` only. Moving a record to another container is a staff edit. + ## `delivery` Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: [Public pages](/docs/content-engine/public-pages) and [SEO](/docs/content-engine/seo). diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 98a8e258d5..17c5eeaf78 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -1140,6 +1140,84 @@ inspects `failures` and retries the whole delivery when it is non-empty. [Services and API](/docs/content-engine/services). +## Content container events + +Four names, declared in `VitNodeEvents` in +`packages/vitnode/src/api/models/events.ts` and shared by every +[container collection](/docs/content-engine/containers). Each fires **after** +the write's transaction has committed. A refused write, such as deleting a board +that still has discussions, emits nothing, and so does an update that changes +nothing. + +| Event | Payload | Fires when | +| --------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `content.container.created` | `{ definitionId, pluginId, containerId, parentId }` | A container is created | +| `content.container.updated` | `{ definitionId, pluginId, containerId, parentId, changedFields }` | Its name, slug or a metadata field actually changed | +| `content.container.moved` | `{ definitionId, pluginId, containerId, parentId, fromParentId, position }` | It moved to another parent, another position, or both | +| `content.container.deleted` | `{ definitionId, pluginId, containerId, parentId }` | An empty container is deleted | + + + + +Emitted by `createContentContainerService`, so the AdminCP routes and your own +code that uses the service emit the same events. Siblings renumbered by a move +or a delete do not get events of their own. + + + +**A listener would** rebuild a navigation menu kept outside VitNode, or post to +a staff channel when a board is deleted: + +```ts title="Example: announce deleted boards" +export const boardDeletedListener = buildEventListener({ + event: 'content.container.deleted', + name: 'announce-deleted-board', + handler: async (c, payload) => { + if (payload.definitionId !== 'example.boards') return + + await c.get('log').warn(`Board ${payload.containerId} was deleted`) + }, +}) +``` + + + + ## Blog events (`@vitnode/blog`) diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 53ea664442..eb74c6f821 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -1,5 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import type { AnyContentContainerDefinition } from "@/content/containers/types"; import type { AnyContentModel } from "@/content/server/model"; import type { AnyContentTypeDefinition } from "@/content/types"; @@ -21,6 +22,7 @@ export interface BaseBuildModuleReturn< M extends string = string, Routes extends Route

[] = Route

[], > { + contentContainers?: AnyContentContainerDefinition[]; contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; @@ -57,6 +59,7 @@ export function buildModule< pluginId, name, modules, + contentContainers, contentModels, contentTypes, notificationTypes, @@ -67,6 +70,7 @@ export function buildModule< searchIndexers, webSockets = [], }: { + contentContainers?: AnyContentContainerDefinition[]; contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; cronJobs?: BuildCronReturn[]; @@ -84,8 +88,8 @@ export function buildModule< const hono = new OpenAPIHono(); if (routes) { - routes.forEach(({ handler, route }) => { - hono.openapi(route, handler); + routes.forEach(({ handler, hook, route }) => { + hono.openapi(route, handler, hook as never); }); } @@ -101,6 +105,7 @@ export function buildModule< hono, name, modules, + contentContainers, contentModels, contentTypes, cronJobs, diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index db839f7a03..8bd15bf6b3 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -1,6 +1,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { BlockPluginSource } from "@/blocks/types"; +import type { AnyContentContainerDefinition } from "@/content/containers/types"; import type { AnyEditablePageDefinition } from "@/content/editor/types"; import type { RegisteredContentType } from "@/content/registry"; import type { AnyContentModel } from "@/content/server/model"; @@ -10,6 +11,11 @@ import type { LocaleMessagesMap } from "@/lib/i18n/types"; import type { NavigationPresetDeclaration } from "@/lib/navigation"; import { BlockError } from "@/blocks/errors"; +import { + assertContentContainerNamespaces, + contentContainerPermissionEntries, + validateContentContainers, +} from "@/content/containers/registry"; import { contentPublicUrls } from "@/content/public-urls"; import { validateContentTypes, @@ -49,6 +55,7 @@ export interface BuildPluginApiReturn< > { aiActions?: AnyAiActionDefinition[]; blocks?: BlockPluginSource; + contentContainers?: AnyContentContainerDefinition[]; contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; cronJobs?: Omit[]; @@ -123,6 +130,7 @@ export function buildApiPlugin< collectAiActions([{ aiActions, pluginId }]); const hono = new OpenAPIHono(); + const contentContainers: AnyContentContainerDefinition[] = []; const contentModels: AnyContentModel[] = []; const contentTypes: AnyContentTypeDefinition[] = []; const publicContentTypes: AnyContentTypeDefinition[] = []; @@ -140,6 +148,9 @@ export function buildApiPlugin< hono.route(`/${handler.name}`, handler.hono); + contentContainers.push( + ...collectModuleTree(handler, m => m.contentContainers), + ); contentModels.push(...collectContentModels(handler)); contentTypes.push(...collectContentTypes(handler)); publicContentTypes.push(...collectPublicContentTypes(handler)); @@ -169,6 +180,18 @@ export function buildApiPlugin< contentTypes.map(definition => ({ definition, pluginId })), ); + const registeredContainers = validateContentContainers( + contentContainers.map(definition => ({ definition, pluginId })), + ).map(entry => entry.definition); + assertContentContainerNamespaces({ + containers: registeredContainers, + contentTypes: [ + ...registered.map(entry => entry.definition), + ...publicContentTypes, + ], + pluginId, + }); + validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId }))); validateNotificationTypes( @@ -202,6 +225,7 @@ export function buildApiPlugin< navigation, hono, openApiTags: [...new Set(openApiTags)], + contentContainers: registeredContainers, contentModels, contentTypes: registered.map(entry => entry.definition), publicContentTypes: [...publishing.values()], @@ -214,13 +238,31 @@ export function buildApiPlugin< // Every content type contributes can_view/can_create/can_edit/can_delete // unless the plugin declared that module itself. permissionStaff: withModerationPermissions( - withContentPermissions(permissionStaff, registered, publicContentTypes), + withContainerPermissions( + withContentPermissions(permissionStaff, registered, publicContentTypes), + registeredContainers, + ), reportTargets, ), moderation: { reportTargets, scopes: moderation?.scopes ?? [] }, }; } +function withContainerPermissions( + permissionStaff: PermissionStaffConfig | undefined, + containers: readonly AnyContentContainerDefinition[], +): PermissionStaffConfig | undefined { + if (containers.length === 0) return permissionStaff; + + const admin = { ...permissionStaff?.admin }; + for (const definition of containers) { + admin[definition.admin.permissionModule] ??= + contentContainerPermissionEntries(); + } + + return { ...permissionStaff, admin }; +} + /** * Walks the whole module tree. Content types are collected recursively - unlike * `events`, `cronJobs` and friends, which only come from top-level modules - so diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index c2b16a03aa..7e32640b5a 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -1,5 +1,6 @@ import type { RouteConfig, RouteHandler } from "@hono/zod-openapi"; -import type { MiddlewareHandler } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +import type { ZodError } from "zod"; import { createRoute as createRouteHono } from "@hono/zod-openapi"; @@ -10,6 +11,11 @@ import { pluginMiddleware } from "../middlewares/plugin.middleware"; import { assertStaffPermission } from "./check-staff-permission"; import { pluginTag } from "./openapi-tags"; +export type RouteValidationHook = ( + result: { error: ZodError; success: false } | { success: true }, + c: Context, +) => Promise | Response | undefined; + export interface AdminStaffPermission { module: string; permission: string; @@ -61,11 +67,13 @@ export const buildRoute = < >({ route, handler, + hook, pluginId, adminStaffPermission, }: { adminStaffPermission?: AdminStaffPermission; handler: RouteHandler; + hook?: RouteValidationHook; pluginId: Plugin; route: R; }): Route => { @@ -108,6 +116,7 @@ export const buildRoute = < middleware, }), handler: handler as Route["handler"], + ...(hook ? { hook } : {}), pluginId, }; }; @@ -117,6 +126,7 @@ export interface Route< R extends RouteConfig = RouteConfig, > { handler: (...args: unknown[]) => Promise | Response; + hook?: RouteValidationHook; pluginId: Plugin; route: R; } diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 13e1ce0591..22a2f10b64 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -29,6 +29,11 @@ import { import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; +import { + assertContentContainerReferences, + type RegisteredContentContainer, + validateContentContainers, +} from "@/content/containers/registry"; import { validateContentTypes } from "@/content/registry"; import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver"; import { warnAboutContentPreviewConfig } from "@/content/server/preview-config"; @@ -133,6 +138,7 @@ export interface EnvVariablesVitNode { }; captcha?: Pick["captcha"]; + contentContainers: RegisteredContentContainer[]; contentModels: RegisteredContentModel[]; contentPreviewSecret?: string; @@ -330,6 +336,22 @@ export const globalMiddleware = ({ ), ); + const contentContainersMetadata = validateContentContainers( + plugins.flatMap(plugin => + (plugin.contentContainers ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + ); + assertContentContainerReferences( + [ + ...contentTypesMetadata.map(entry => entry.definition), + ...plugins.flatMap(plugin => plugin.publicContentTypes ?? []), + ], + contentContainersMetadata, + ); + // Once, here, because "does anything have preview enabled" is only answerable // after every plugin's content types are in. A warning, never a boot failure: // preview is one content type's opt-in feature, not a prerequisite for the @@ -509,6 +531,7 @@ export const globalMiddleware = ({ navigation: navigationMetadata, notifications: notificationsMetadata, notificationWorkers, + contentContainers: contentContainersMetadata, contentModels: contentModelsMetadata, contentRevalidateOrigins: content?.revalidateOrigins, contentTypes: contentTypesMetadata, diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 4a78722a43..6c440826aa 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -7,6 +7,13 @@ import type { SsoProfileField } from "@/lib/sso-profile"; import type { EventListenerConfig } from "../lib/events"; +export interface ContentContainerEventPayload { + containerId: number; + definitionId: string; + parentId: null | number; + pluginId: string; +} + export interface VitNodeEvents { "ai.run.completed": { actionKey: string; @@ -21,6 +28,15 @@ export interface VitNodeEvents { runId: number; userId: null | number; }; + "content.container.created": ContentContainerEventPayload; + "content.container.deleted": ContentContainerEventPayload; + "content.container.moved": ContentContainerEventPayload & { + fromParentId: null | number; + position: number; + }; + "content.container.updated": ContentContainerEventPayload & { + changedFields: string[]; + }; "core.page-layout.updated": { changedZones: string[]; pageId: string; diff --git a/packages/vitnode/src/blocks/capabilities.ts b/packages/vitnode/src/blocks/capabilities.ts index 69e2057833..3d538cbb9c 100644 --- a/packages/vitnode/src/blocks/capabilities.ts +++ b/packages/vitnode/src/blocks/capabilities.ts @@ -32,6 +32,8 @@ const REFERENCE_LIFECYCLE = const DEFERRED: Partial> = { blocks: "nested blocks are a later stage: a block that contains a zone needs the zone's own allowlist, depth limits and cycle detection before it can be stored safely", + container: + "a container is where a whole record is filed - one forum board, one blog section. A block is part of a record, not a record of its own, so it has nowhere to be filed", file: `${REFERENCE_LIFECYCLE}. Supporting it means extracting the references out of a block's data on write and pinning them exactly as a \`file\` column is pinned, with \`ON DELETE RESTRICT\`, so the Files screen answers 409 instead of orphaning a page`, relation: `${REFERENCE_LIFECYCLE}. It also needs somewhere to honour \`onDelete\`, which for a column is the foreign key itself`, repeatable: diff --git a/packages/vitnode/src/content/admin/containers.ts b/packages/vitnode/src/content/admin/containers.ts new file mode 100644 index 0000000000..b2f8dc0d40 --- /dev/null +++ b/packages/vitnode/src/content/admin/containers.ts @@ -0,0 +1,82 @@ +import type { AnyContentContainerDefinition } from "../containers/types"; +import type { ContentLabelTranslator } from "./labels"; + +import { contentEntityKey, humanizeFieldName } from "./labels"; + +type ContentContainerLabelSubject = Pick; + +export const contentContainerAdminHref = ( + definition: Pick, +): string => `/admin/containers/${definition.admin.path}`; + +export const contentContainerI18nKeys = ( + definition: ContentContainerLabelSubject, + pluginId: string, +) => { + const base = `${pluginId}.containers.${contentEntityKey(definition.id)}`; + + return { + base, + desc: `${base}.desc`, + enumValue: (field: string, value: string) => + `${base}.enums.${field}.${value}`, + field: (field: string) => `${base}.fields.${field}`, + label: `${base}.label`, + title: `${base}.title`, + }; +}; + +export const contentContainerNamespace = ( + definition: ContentContainerLabelSubject, + pluginId: string, +): string => contentContainerI18nKeys(definition, pluginId).base; + +export const contentContainerNouns = ( + definition: ContentContainerLabelSubject, + pluginId: string, + t: ContentLabelTranslator, +): { plural: string; singular: string; title: string } => { + const keys = contentContainerI18nKeys(definition, pluginId); + const fallback = humanizeFieldName(contentEntityKey(definition.id)); + const noun = (count: number): string => + t.has(keys.label) ? t(keys.label, { count }) : fallback; + const plural = noun(2); + + return { + plural, + singular: noun(1), + title: t.has(keys.title) ? t(keys.title) : plural, + }; +}; + +export interface ContentContainerLabels { + desc: string | undefined; + labelEnum: (field: string, value: string) => string; + labelField: (name: string) => string; + plural: string; + singular: string; + title: string; +} + +export const contentContainerLabelsFrom = ( + definition: ContentContainerLabelSubject, + pluginId: string, + t: ContentLabelTranslator, +): ContentContainerLabels => { + const keys = contentContainerI18nKeys(definition, pluginId); + + return { + desc: t.has(keys.desc) ? t(keys.desc) : undefined, + labelEnum: (field, value) => { + const key = keys.enumValue(field, value); + + return t.has(key) ? t(key) : humanizeFieldName(value); + }, + labelField: name => { + const key = keys.field(name); + + return t.has(key) ? t(key) : humanizeFieldName(name); + }, + ...contentContainerNouns(definition, pluginId, t), + }; +}; diff --git a/packages/vitnode/src/content/admin/registry.ts b/packages/vitnode/src/content/admin/registry.ts index 7e8e789db8..5c171e0661 100644 --- a/packages/vitnode/src/content/admin/registry.ts +++ b/packages/vitnode/src/content/admin/registry.ts @@ -1,10 +1,13 @@ import type { + ContentContainerFrontendRegistration, ContentFrontendPluginSource, ContentTypeFrontendRegistration, } from "../../lib/plugin"; +import type { AnyContentContainerDefinition } from "../containers/types"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentTypeLookup } from "./route"; +import { validateContentContainers } from "../containers/registry"; import { validateContentTypes } from "../registry"; /** A content type's frontend registration, plus the plugin that registered it. */ @@ -14,6 +17,22 @@ export interface RegisteredFrontendContentType { registration: ContentTypeFrontendRegistration; } +export interface RegisteredFrontendContentContainer { + definition: AnyContentContainerDefinition; + pluginId: string; + registration: ContentContainerFrontendRegistration; +} + +export interface ContentContainerFrontendRegistry { + all: () => readonly RegisteredFrontendContentContainer[]; + byAdminPath: ( + adminPath: string, + ) => RegisteredFrontendContentContainer | undefined; + byId: ( + definitionId: string, + ) => RegisteredFrontendContentContainer | undefined; +} + export interface ContentFrontendRegistry { /** Every registration, sorted by content type id. */ all: () => readonly RegisteredFrontendContentType[]; @@ -21,10 +40,39 @@ export interface ContentFrontendRegistry { byAdminPath: (adminPath: string) => RegisteredFrontendContentType | undefined; /** By `definition.id` - `blog.post`. */ byId: (contentTypeId: string) => RegisteredFrontendContentType | undefined; + containers: ContentContainerFrontendRegistry; lookup: ContentTypeLookup; } +const buildContainerRegistry = ( + plugins: readonly ContentFrontendPluginSource[], +): ContentContainerFrontendRegistry => { + const entries = plugins.flatMap(plugin => + (plugin.containers ?? []).map(registration => ({ + definition: registration.definition, + pluginId: plugin.pluginId, + registration, + })), + ); + + const firstRegistrations = new Set(validateContentContainers(entries)); + const all = entries + .filter(entry => firstRegistrations.has(entry)) + .sort((a, b) => a.definition.id.localeCompare(b.definition.id)); + + const byId = new Map(all.map(entry => [entry.definition.id, entry])); + const byAdminPath = new Map( + all.map(entry => [entry.definition.admin.path, entry]), + ); + + return { + all: () => all, + byAdminPath: adminPath => byAdminPath.get(adminPath), + byId: definitionId => byId.get(definitionId), + }; +}; + export const buildContentFrontendRegistry = ( plugins: readonly ContentFrontendPluginSource[], ): ContentFrontendRegistry => { @@ -53,6 +101,7 @@ export const buildContentFrontendRegistry = ( all: () => all, byAdminPath: adminPath => byAdminPath.get(adminPath), byId: contentTypeId => byId.get(contentTypeId), + containers: buildContainerRegistry(plugins), lookup: adminPath => byAdminPath.get(adminPath)?.definition, }; }; diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 7898957b54..9afe73843b 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -39,6 +39,7 @@ export interface ContentFormFieldSpec { allowedExtensions?: string[]; /** `file` fields only: the media types the field accepts, lowercased. */ allowedMimeTypes?: string[]; + containerDefinitionId?: string; defaultValue?: boolean | null | number | string; description?: string; display?: "radio" | "select"; @@ -101,6 +102,7 @@ export interface ContentFormSpec { } export interface ContentColumnSpec { + containerDefinitionId?: string; kind: "publication" | "system" | ContentFieldKind; label: string; @@ -156,6 +158,8 @@ export const projectFormField = ( switch (fieldValue.kind) { case "boolean": return { ...base, defaultValue: fieldValue.defaultValue }; + case "container": + return { ...base, containerDefinitionId: fieldValue.definition.id }; case "enum": return { ...base, @@ -359,6 +363,9 @@ export const buildContentColumnSpec = ({ ...(fieldValue && isContentReferenceCollection(fieldValue) ? { multiple: true } : {}), + ...(fieldValue?.kind === "container" + ? { containerDefinitionId: fieldValue.definition.id } + : {}), ...(name === definition.admin.titleField ? { title: true } : {}), ...(name === definition.admin.titleField && definition.admin.list.thumbnailField !== null @@ -466,6 +473,8 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { return z.array(zodContentNode); case "boolean": return z.boolean(); + case "container": + return z.number().int().positive(); case "dateTime": // ISO strings all the way through the form - `z.toJSONSchema`, which // AutoForm runs on every schema, throws on `z.date()`. diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index d09989df3f..afdfef604a 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -57,6 +57,9 @@ export const contentDeliverySitemapTag = ( locale?: string, ): string => tag(contentTypeId, "sitemap", ...localeParts(locale)); +export const contentContainerTag = (definitionId: string): string => + tag(definitionId, "containers"); + export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; export interface ContentLocaleInvalidation { diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 58e7baedd1..f334665d3f 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -44,6 +44,7 @@ export const CONTENT_PUBLICATION_STATUS_LENGTH = 32; export const CONTENT_FILTERABLE_FIELD_KINDS = [ "boolean", + "container", "enum", "number", "relation", @@ -211,6 +212,7 @@ export const CONTENT_PUBLIC_RESERVED_PATHS = ["admin"] as const; export const CONTENT_PUBLIC_EXPOSABLE_KINDS = [ "blocks", "boolean", + "container", "dateTime", "enum", // A file is exposable, and what crosses is the normalised descriptor rather @@ -238,6 +240,7 @@ export const CONTENT_PUBLIC_ALWAYS_ORDERABLE = "publishedAt"; export const CONTENT_PUBLIC_WRITABLE_KINDS = [ "boolean", + "container", "dateTime", "enum", "number", diff --git a/packages/vitnode/src/content/containers/association.ts b/packages/vitnode/src/content/containers/association.ts new file mode 100644 index 0000000000..a46156e1af --- /dev/null +++ b/packages/vitnode/src/content/containers/association.ts @@ -0,0 +1,49 @@ +import type { AnyContentTypeDefinition, ContentContainerField } from "../types"; + +import { ContentInputError } from "../errors"; + +export interface ContentContainerFieldEntry { + field: ContentContainerField; + name: string; +} + +export const contentContainerFieldOf = ( + definition: Pick, +): ContentContainerFieldEntry | null => { + for (const [name, field] of Object.entries(definition.fields)) { + if (field.kind === "container") return { field, name }; + } + + return null; +}; + +export type ContentContainerAssignmentCode = + | "CONTAINER_DEFINITION_MISMATCH" + | "CONTAINER_NOT_FOUND"; + +export class ContentContainerAssignmentError extends ContentInputError { + constructor({ + code, + contentTypeId, + field, + }: { + code: ContentContainerAssignmentCode; + contentTypeId: string; + field: string; + }) { + const message = + code === "CONTAINER_NOT_FOUND" + ? "The selected container does not exist." + : "The selected container belongs to a different collection."; + super(message, { contentTypeId }); + + this.name = "ContentContainerAssignmentError"; + this.code = code; + this.detail = message; + this.field = field; + } + + readonly code: ContentContainerAssignmentCode; + readonly detail: string; + readonly field: string; +} diff --git a/packages/vitnode/src/content/containers/const.ts b/packages/vitnode/src/content/containers/const.ts new file mode 100644 index 0000000000..1a532c9e6f --- /dev/null +++ b/packages/vitnode/src/content/containers/const.ts @@ -0,0 +1,57 @@ +export const CONTENT_CONTAINER_FIELD_KINDS = [ + "boolean", + "enum", + "number", + "text", + "textarea", +] as const; + +export const CONTENT_CONTAINER_COLUMNS = [ + "id", + "parentId", + "name", + "slug", + "position", + "createdAt", + "updatedAt", +] as const; + +export const CONTENT_CONTAINER_RESERVED_FIELDS = [ + ...CONTENT_CONTAINER_COLUMNS, + "children", + "definitionId", + "depth", + "metadata", + "pluginId", +] as const; + +export const CONTENT_CONTAINER_ORDERING_MODES = [ + "manual", + "alphabetical", +] as const; + +export const CONTENT_CONTAINER_DEFAULT_MAX_DEPTH = 5; + +export const CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH = 10; + +export const CONTENT_CONTAINER_NAME_MAX_LENGTH = 255; + +export const CONTENT_CONTAINER_SLUG_MAX_LENGTH = 160; + +export const CONTENT_CONTAINER_MAX_PER_DEFINITION = 5000; + +export const CONTENT_CONTAINER_ERROR_CODES = [ + "cycle", + "definitionMismatch", + "hasChildren", + "hasContent", + "invalidInput", + "invalidMetadata", + "invalidPosition", + "limitReached", + "maxDepth", + "notFound", + "parentNotFound", + "slugEmpty", + "slugTaken", +] as const; diff --git a/packages/vitnode/src/content/containers/define.test-d.ts b/packages/vitnode/src/content/containers/define.test-d.ts new file mode 100644 index 0000000000..ec6c41affe --- /dev/null +++ b/packages/vitnode/src/content/containers/define.test-d.ts @@ -0,0 +1,93 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { ContentCreateInput } from "../types"; +import type { + ContentContainerPublicRecord, + ContentContainerRecord, +} from "./types"; + +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { defineContentContainer } from "./define"; + +const boards = defineContentContainer({ + id: "test.boards", + fields: { + color: field.text({ maxLength: 7, required: true }), + locked: field.boolean({ defaultValue: false }), + kind: field.enum({ values: ["forum", "blog"], nullable: true }), + secret: field.number({ integer: true, nullable: true }), + }, + publicApi: { path: "boards", fields: ["color", "kind"] }, +}); + +const sections = defineContentContainer({ id: "test.sections" }); + +const thread = (required: TRequired) => + defineContentType({ + id: "test.thread", + tableName: "test_threads", + fields: { + title: field.text({ required: true }), + board: field.container({ definition: boards, required }), + }, + }); + +describe("content container types", () => { + it("infers the metadata from the fields, every value nullable", () => { + expectTypeOf< + ContentContainerRecord["metadata"] + >().toEqualTypeOf< + Readonly<{ + color: null | string; + kind: "blog" | "forum" | null; + locked: boolean | null; + secret: null | number; + }> + >(); + }); + + it("limits the public metadata to publicApi.fields", () => { + expectTypeOf< + ContentContainerPublicRecord["metadata"] + >().toEqualTypeOf< + Readonly<{ color: null | string; kind: "blog" | "forum" | null }> + >(); + }); + + it("has no metadata for a definition without fields", () => { + expectTypeOf< + ContentContainerRecord["metadata"] + >().toEqualTypeOf>(); + expectTypeOf< + ContentContainerPublicRecord["metadata"] + >().toEqualTypeOf>(); + }); + + it("keeps the public API flag and path literal", () => { + expectTypeOf(boards.publicApi.enabled).toEqualTypeOf(); + expectTypeOf(boards.publicApi.path).toEqualTypeOf<"boards">(); + expectTypeOf(sections.publicApi.enabled).toEqualTypeOf(); + }); + + it("rejects a public field the definition does not declare", () => { + defineContentContainer({ + id: "test.boards", + fields: { color: field.text({ nullable: true }) }, + // @ts-expect-error - "nope" is not a field + publicApi: { path: "boards", fields: ["nope"] }, + }); + }); + + it("makes a required container a required number on create", () => { + expectTypeOf< + ContentCreateInput>> + >().toEqualTypeOf<{ board: number; title: string }>(); + }); + + it("makes an optional container an optional number or null on create", () => { + expectTypeOf< + ContentCreateInput>> + >().toEqualTypeOf<{ board?: null | number; title: string }>(); + }); +}); diff --git a/packages/vitnode/src/content/containers/define.test.ts b/packages/vitnode/src/content/containers/define.test.ts new file mode 100644 index 0000000000..0b933f788d --- /dev/null +++ b/packages/vitnode/src/content/containers/define.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from "vitest"; + +import { testCategoryContentType } from "@/tests/content-fixtures"; + +import { ContentEngineError } from "../errors"; +import { field } from "../fields"; +import { defineContentContainer } from "./define"; + +const define = (overrides: Record = {}) => + defineContentContainer({ id: "test.boards", ...overrides }); + +const boards = defineContentContainer({ + id: "test.boards", + fields: { + description: field.textarea({ maxLength: 20, nullable: true }), + color: field.text({ maxLength: 7, required: true }), + locked: field.boolean({ defaultValue: false }), + secret: field.text({ nullable: true }), + }, + publicApi: { path: "boards", fields: ["description", "color"] }, +}); + +describe("defineContentContainer", () => { + describe("a valid definition", () => { + it("resolves the defaults", () => { + const definition = defineContentContainer({ id: "example.kb.sections" }); + + expect(definition).toMatchObject({ + admin: { + navigation: { enabled: true }, + path: "example/kb/sections", + permissionModule: "example_kb_sections", + }, + fields: {}, + hierarchy: { enabled: true, maxDepth: 5 }, + id: "example.kb.sections", + kind: "contentContainer", + ordering: { mode: "manual" }, + publicApi: { enabled: false, fields: [], path: "" }, + }); + }); + + it("snake_cases a dashed id into the permission module", () => { + expect(define({ id: "test.help-desk" }).admin.permissionModule).toBe( + "test_help_desk", + ); + }); + + it("keeps explicit options", () => { + const definition = define({ + admin: { + navigation: false, + path: "forum/boards", + permissionModule: "boards", + }, + hierarchy: { maxDepth: 3 }, + ordering: { mode: "alphabetical" }, + publicApi: { path: "boards" }, + }); + + expect(definition).toMatchObject({ + admin: { + navigation: { enabled: false }, + path: "forum/boards", + permissionModule: "boards", + }, + hierarchy: { enabled: true, maxDepth: 3 }, + ordering: { mode: "alphabetical" }, + publicApi: { enabled: true, fields: [], path: "boards" }, + }); + }); + + it("turns a disabled hierarchy into one level", () => { + expect(define({ hierarchy: { enabled: false } }).hierarchy).toEqual({ + enabled: false, + maxDepth: 1, + }); + }); + + it("treats publicApi: false as disabled", () => { + expect(define({ publicApi: false }).publicApi.enabled).toBe(false); + }); + + it("accepts every scalar field kind", () => { + expect(() => + define({ + fields: { + count: field.number({ integer: true, nullable: true }), + description: field.textarea({ nullable: true }), + kind: field.enum({ values: ["a", "b"], defaultValue: "a" }), + locked: field.boolean({ defaultValue: false }), + title: field.text({ required: true }), + }, + }), + ).not.toThrow(); + }); + }); + + describe("errors", () => { + it.each([ + ["an id without a dot", { id: "boards" }, /must look like/], + ["an uppercase id", { id: "Test.Boards" }, /must look like/], + ["an id with a trailing dot", { id: "test." }, /must look like/], + ["a non-string id", { id: 1 }, /must look like/], + ["fields that are not an object", { fields: [] }, /fields is an object/], + [ + "a field name that is not camelCase", + { fields: { Color: field.text() } }, + /must be camelCase/, + ], + ...[ + "name", + "slug", + "parentId", + "position", + "metadata", + "depth", + "children", + ].map(name => [ + `the reserved field ${name}`, + { fields: { [name]: field.text() } }, + new RegExp(`Field "${name}" is reserved`), + ]), + [ + "a value that is not a field descriptor", + { fields: { color: "red" } }, + /"color" is not a field descriptor/, + ], + ...( + [ + ["richText", field.richText()], + [ + "relation", + field.relation({ target: () => testCategoryContentType }), + ], + ["user", field.user()], + [ + "file", + field.file({ + allowedExtensions: [".png"], + allowedMimeTypes: ["image/png"], + maxBytes: 1024, + }), + ], + ["dateTime", field.dateTime({ nullable: true })], + ] as [string, unknown][] + ).map(([kind, descriptor]) => [ + `a ${kind} field`, + { fields: { extra: descriptor } }, + new RegExp(`"extra" is a "${kind}" field`), + ]), + [ + "a localized field", + { fields: { color: field.text({ localized: true }) } }, + /`localized: true`/, + ], + [ + "a unique field", + { fields: { color: field.text({ unique: true }) } }, + /`unique: true`/, + ], + [ + "an AI-assisted field", + { + fields: { + color: field.text({ + ai: { action: "x:y", mode: "suggestion", sourceFields: [] }, + }), + }, + }, + /AI assist is for content types only/, + ], + [ + "minLength greater than maxLength", + { fields: { color: field.text({ maxLength: 5, minLength: 10 }) } }, + /minLength 10 greater than maxLength 5/, + ], + ...[1, 0, 11, 1.5].map(maxDepth => [ + `hierarchy.maxDepth ${maxDepth}`, + { hierarchy: { maxDepth } }, + /maxDepth must be a whole number from 2 to 10/, + ]), + [ + "maxDepth on a disabled hierarchy", + { hierarchy: { enabled: false, maxDepth: 3 } }, + /maxDepth is set while hierarchy is disabled/, + ], + [ + "an unknown hierarchy key", + { hierarchy: { depth: 3 } }, + /hierarchy.depth is not an option/, + ], + [ + "a non-boolean hierarchy.enabled", + { hierarchy: { enabled: "yes" } }, + /hierarchy.enabled is `true` or `false`/, + ], + [ + "an unknown ordering mode", + { ordering: { mode: "newest" } }, + /ordering.mode must be one of: manual, alphabetical/, + ], + [ + "an unknown ordering key", + { ordering: { direction: "asc" } }, + /ordering.direction is not an option/, + ], + [ + "a publicApi path with uppercase letters", + { publicApi: { path: "Boards" } }, + /publicApi.path "Boards" must be one lowercase URL segment/, + ], + [ + "a publicApi path with a slash", + { publicApi: { path: "forum/boards" } }, + /must be one lowercase URL segment/, + ], + [ + "the reserved publicApi path admin", + { publicApi: { path: "admin" } }, + /publicApi.path "admin" is reserved/, + ], + [ + "an unknown publicApi key", + { publicApi: { path: "boards", filterableFields: [] } }, + /publicApi.filterableFields is not an option/, + ], + [ + "an unknown publicApi field", + { publicApi: { path: "boards", fields: ["nope"] } }, + /publicApi.fields references unknown field "nope"/, + ], + [ + "a publicApi field listed twice", + { + fields: { color: field.text({ nullable: true }) }, + publicApi: { path: "boards", fields: ["color", "color"] }, + }, + /lists "color" twice/, + ], + [ + "a core column in publicApi.fields", + { publicApi: { path: "boards", fields: ["slug"] } }, + /lists "slug", which every public container already returns/, + ], + ...["/forum", "forum/", "forum//boards", "Forum", "forum_boards"].map( + path => [ + `the admin path ${path}`, + { admin: { path } }, + /admin.path .* is not a URL path/, + ], + ), + ...["Boards", "1boards", "forum-boards"].map(permissionModule => [ + `the permission module ${permissionModule}`, + { admin: { permissionModule } }, + /admin.permissionModule .* must be snake_case/, + ]), + [ + "an unknown admin key", + { admin: { title: "Boards" } }, + /admin.title is not an option/, + ], + ] as [string, Record, RegExp][])( + "rejects %s", + (_label, overrides, message) => { + expect(() => define(overrides)).toThrow(ContentEngineError); + expect(() => define(overrides)).toThrow(message); + }, + ); + }); + + describe("schemas", () => { + const { schemas } = boards; + + it.each([ + ["a name only", { name: "General" }], + [ + "every option", + { + metadata: { color: "#fff" }, + name: "General", + parentId: 3, + position: 0, + slug: "general", + }, + ], + ["a root parent", { name: "General", parentId: null }], + ])("create accepts %s", (_label, body) => { + expect(schemas.create.safeParse(body).success).toBe(true); + }); + + it.each([ + ["no name", {}], + ["a blank name", { name: " " }], + ["a name past 255 characters", { name: "a".repeat(256) }], + ["a slug past 160 characters", { name: "A", slug: "a".repeat(161) }], + ["a zero parent id", { name: "A", parentId: 0 }], + ["a negative position", { name: "A", position: -1 }], + ["a fractional position", { name: "A", position: 1.5 }], + ["an unknown key", { depth: 2, name: "A" }], + ["metadata that misses a required field", { metadata: {}, name: "A" }], + ])("create rejects %s", (_label, body) => { + expect(schemas.create.safeParse(body).success).toBe(false); + }); + + it.each([ + ["a name", { name: "Renamed" }], + ["a partial metadata patch", { metadata: { locked: true } }], + ])("update accepts %s", (_label, body) => { + expect(schemas.update.safeParse(body).success).toBe(true); + }); + + it.each([ + ["an empty body", {}], + ["a parent id, which is a move", { parentId: 1 }], + ["a position, which is a move", { position: 1 }], + ["an unknown metadata key", { metadata: { nope: 1 } }], + ])("update rejects %s", (_label, body) => { + expect(schemas.update.safeParse(body).success).toBe(false); + }); + + it.each([ + ["a root", { parentId: null, position: 0 }, true], + ["a parent", { parentId: 4, position: 2 }, true], + ["a missing parent id", { position: 0 }, false], + ["a missing position", { parentId: null }, false], + ["a negative position", { parentId: null, position: -1 }, false], + ["an extra key", { name: "A", parentId: null, position: 0 }, false], + ])("move with %s", (_label, body, success) => { + expect(schemas.move.safeParse(body).success).toBe(success); + }); + + it.each([ + ["every field", { color: "#fff", description: "Hi", locked: true }, true], + ["only the required field", { color: "#fff" }, true], + ["a null nullable field", { color: "#fff", description: null }, true], + ["the required field missing", { description: "Hi" }, false], + ["the required field null", { color: null }, false], + ["a value past maxLength", { color: "#ffffff00" }, false], + ["a wrong type", { color: "#fff", locked: "yes" }, false], + ["an unknown field", { color: "#fff", extra: 1 }, false], + ])("metadata with %s", (_label, metadata, success) => { + expect(schemas.metadata.safeParse(metadata).success).toBe(success); + }); + + it("strips non-public metadata from a public record", () => { + const parsed = schemas.publicRecord.parse({ + depth: 1, + id: 1, + metadata: { + color: "#fff", + description: null, + locked: true, + secret: "s3cret", + }, + name: "General", + parentId: null, + position: 0, + slug: "general", + }); + + expect(parsed.metadata).toEqual({ color: "#fff", description: null }); + }); + }); +}); diff --git a/packages/vitnode/src/content/containers/define.ts b/packages/vitnode/src/content/containers/define.ts new file mode 100644 index 0000000000..755f5d1b57 --- /dev/null +++ b/packages/vitnode/src/content/containers/define.ts @@ -0,0 +1,389 @@ +import { z } from "zod"; + +import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; +import type { + ContentContainerAdminInput, + ContentContainerDefinition, + ContentContainerFieldMap, + ContentContainerHierarchyInput, + ContentContainerOrderingInput, + ContentContainerPublicApiInput, + ContentContainerSchemas, +} from "./types"; + +import { + CONTENT_ADMIN_PATH_SEGMENT_PATTERN, + CONTENT_FIELD_NAME_PATTERN, + CONTENT_ID_PATTERN, + CONTENT_PUBLIC_PATH_MAX_LENGTH, + CONTENT_PUBLIC_PATH_PATTERN, + CONTENT_PUBLIC_RESERVED_PATHS, + CONTENT_TABLE_NAME_PATTERN, +} from "../const"; +import { ContentEngineError } from "../errors"; +import { scalarFieldConstraintIssue } from "../field-constraints"; +import { contentTypeToPath } from "../registry"; +import { + contentFieldPatchObject, + contentFieldSelectSchema, + contentFieldValuesObject, +} from "../schemas"; +import { + CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH, + CONTENT_CONTAINER_DEFAULT_MAX_DEPTH, + CONTENT_CONTAINER_FIELD_KINDS, + CONTENT_CONTAINER_NAME_MAX_LENGTH, + CONTENT_CONTAINER_ORDERING_MODES, + CONTENT_CONTAINER_RESERVED_FIELDS, + CONTENT_CONTAINER_SLUG_MAX_LENGTH, +} from "./const"; + +const fieldKinds: ReadonlySet = new Set(CONTENT_CONTAINER_FIELD_KINDS); +const reservedFields: readonly string[] = CONTENT_CONTAINER_RESERVED_FIELDS; +const orderingModes: readonly string[] = CONTENT_CONTAINER_ORDERING_MODES; +const reservedPublicPaths: readonly string[] = CONTENT_PUBLIC_RESERVED_PATHS; + +const fail = (id: string, message: string): never => { + throw new ContentEngineError(message, { contentTypeId: id }); +}; + +const assertKnownKeys = ( + id: string, + label: string, + value: unknown, + known: readonly string[], +): void => { + if (value === undefined) return; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(id, `${label} is an object.`); + } + + const unknown = Object.keys(value as object).find( + key => !known.includes(key), + ); + if (unknown !== undefined) { + fail( + id, + `${label}.${unknown} is not an option. ${label} accepts: ${known.join(", ")}.`, + ); + } +}; + +const assertContainerField = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { + fail( + id, + `Field "${name}" must be camelCase and start with a lowercase letter.`, + ); + } + + if (reservedFields.includes(name)) { + fail( + id, + `Field "${name}" is reserved. Every container already has ${reservedFields.join(", ")} - declare only what your containers add on top.`, + ); + } + + if ( + !fieldKinds.has((fieldValue as undefined | { kind?: string })?.kind ?? "") + ) { + fail( + id, + `Field "${name}" is ${fieldValue?.kind ? `a "${fieldValue.kind}" field` : "not a field descriptor"}. A container field is one value stored beside the container - use field.${CONTENT_CONTAINER_FIELD_KINDS.join("(), field.")}().`, + ); + } + + if (fieldValue.localized === true) { + fail( + id, + `Field "${name}" is \`localized: true\`. Container fields have one value in every language.`, + ); + } + + if ((fieldValue as { unique?: boolean }).unique === true) { + fail( + id, + `Field "${name}" is \`unique: true\`. Container fields live in one JSON document per container, which no unique index covers - only the slug is unique.`, + ); + } + + if ((fieldValue as { ai?: unknown }).ai !== undefined) { + fail( + id, + `Field "${name}" has \`ai\`. AI assist is for content types only.`, + ); + } + + const issue = scalarFieldConstraintIssue(name, fieldValue); + if (issue) fail(id, issue); +}; + +const resolveHierarchy = ( + id: string, + hierarchy: ContentContainerHierarchyInput | undefined, +): { enabled: boolean; maxDepth: number } => { + assertKnownKeys(id, "hierarchy", hierarchy, ["enabled", "maxDepth"]); + + const enabled = hierarchy?.enabled ?? true; + if (typeof enabled !== "boolean") + fail(id, "hierarchy.enabled is `true` or `false`."); + + if (!enabled) { + if (hierarchy?.maxDepth !== undefined && hierarchy.maxDepth !== 1) { + fail( + id, + "hierarchy.maxDepth is set while hierarchy is disabled. A flat list is one level deep - drop maxDepth, or enable the hierarchy.", + ); + } + + return { enabled: false, maxDepth: 1 }; + } + + const maxDepth = hierarchy?.maxDepth ?? CONTENT_CONTAINER_DEFAULT_MAX_DEPTH; + if ( + !Number.isInteger(maxDepth) || + maxDepth < 2 || + maxDepth > CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH + ) { + fail( + id, + `hierarchy.maxDepth must be a whole number from 2 to ${CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH}. For a single level, set \`hierarchy: { enabled: false }\`.`, + ); + } + + return { enabled: true, maxDepth }; +}; + +const resolveOrdering = ( + id: string, + ordering: ContentContainerOrderingInput | undefined, +): { mode: (typeof CONTENT_CONTAINER_ORDERING_MODES)[number] } => { + assertKnownKeys(id, "ordering", ordering, ["mode"]); + const mode = ordering?.mode ?? "manual"; + if (!orderingModes.includes(mode)) { + fail(id, `ordering.mode must be one of: ${orderingModes.join(", ")}.`); + } + + return { mode }; +}; + +const resolvePublicApi = ( + id: string, + fields: ContentContainerFieldMap, + publicApi: ContentContainerPublicApiInput | false | undefined, +): { enabled: boolean; fields: string[]; path: string } => { + if (publicApi === undefined || publicApi === false) { + return { enabled: false, fields: [], path: "" }; + } + + assertKnownKeys(id, "publicApi", publicApi, ["fields", "path"]); + const { path } = publicApi; + + if (typeof path !== "string" || !CONTENT_PUBLIC_PATH_PATTERN.test(path)) { + fail( + id, + `publicApi.path ${JSON.stringify(path)} must be one lowercase URL segment: a letter, then letters, digits or dashes.`, + ); + } + + if (path.length > CONTENT_PUBLIC_PATH_MAX_LENGTH) { + fail( + id, + `publicApi.path "${path}" is longer than ${CONTENT_PUBLIC_PATH_MAX_LENGTH} characters.`, + ); + } + + if (reservedPublicPaths.includes(path)) { + fail(id, `publicApi.path "${path}" is reserved.`); + } + + const exposed = [...(publicApi.fields ?? [])].map(String); + for (const [index, name] of exposed.entries()) { + if (exposed.indexOf(name) !== index) { + fail(id, `publicApi.fields lists "${name}" twice.`); + } + + if (fields[name] === undefined) { + fail( + id, + reservedFields.includes(name) + ? `publicApi.fields lists "${name}", which every public container already returns. List only the definition's own fields.` + : `publicApi.fields references unknown field "${name}".`, + ); + } + } + + return { enabled: true, fields: exposed, path }; +}; + +const resolveAdmin = ( + id: string, + admin: ContentContainerAdminInput | undefined, +): ContentContainerDefinition["admin"] => { + assertKnownKeys(id, "admin", admin, [ + "navigation", + "path", + "permissionModule", + ]); + + const path = admin?.path ?? contentTypeToPath(id); + const invalid = path + .split("/") + .find(segment => !CONTENT_ADMIN_PATH_SEGMENT_PATTERN.test(segment)); + if (invalid !== undefined) { + fail( + id, + `admin.path "${path}" is not a URL path. Every segment starts with a lowercase letter and holds only lowercase letters, digits and dashes.`, + ); + } + + const permissionModule = + admin?.permissionModule ?? + id.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + if (!CONTENT_TABLE_NAME_PATTERN.test(permissionModule)) { + fail( + id, + `admin.permissionModule "${permissionModule}" must be snake_case and start with a letter.`, + ); + } + + return { + navigation: { enabled: admin?.navigation ?? true }, + path, + permissionModule, + }; +}; + +const buildSchemas = ( + fields: ContentContainerFieldMap, + publicFields: readonly string[], +): ContentContainerSchemas => { + const fieldMap = fields as ContentFieldMap; + const metadata = contentFieldValuesObject(fieldMap); + const metadataPatch = contentFieldPatchObject(fieldMap); + const name = z.string().trim().min(1).max(CONTENT_CONTAINER_NAME_MAX_LENGTH); + const slug = z.string().trim().min(1).max(CONTENT_CONTAINER_SLUG_MAX_LENGTH); + const id = z.number().int().positive(); + const position = z.number().int().min(0); + + const selectShape = (names: readonly string[]) => + z.object( + Object.fromEntries( + names.map(fieldName => [ + fieldName, + contentFieldSelectSchema(fieldMap[fieldName]).nullable(), + ]), + ), + ); + + const columns = { + depth: z.number().int().positive(), + id, + name: z.string(), + parentId: id.nullable(), + position, + slug: z.string(), + }; + + return { + create: z.strictObject({ + metadata: metadata.optional(), + name, + parentId: id.nullable().optional(), + position: position.optional(), + slug: slug.optional(), + }), + metadata, + metadataPatch, + move: z.strictObject({ parentId: id.nullable(), position }), + publicRecord: z.object({ + ...columns, + metadata: selectShape(publicFields), + }), + record: z.object({ + ...columns, + createdAt: z.date(), + metadata: selectShape(Object.keys(fields)), + updatedAt: z.date(), + }), + update: z + .strictObject({ + metadata: metadataPatch.optional(), + name: name.optional(), + slug: slug.optional(), + }) + .refine(value => Object.keys(value).length > 0, { + message: "Provide at least one value to update.", + }), + }; +}; + +export const defineContentContainer = < + const TId extends string, + const TFields extends ContentContainerFieldMap = Record, + const TPublicField extends keyof TFields & string = never, + const TPublicPath extends string = string, + TPublicApi extends + | ContentContainerPublicApiInput + | false + | undefined = undefined, +>({ + admin, + fields = {} as TFields, + hierarchy, + id, + ordering, + publicApi, +}: { + admin?: ContentContainerAdminInput; + fields?: TFields; + hierarchy?: ContentContainerHierarchyInput; + id: TId; + ordering?: ContentContainerOrderingInput; + publicApi?: ( + | ContentContainerPublicApiInput + | false + ) & + TPublicApi; +}): ContentContainerDefinition< + TId, + TFields, + TPublicField, + TPublicApi extends ContentContainerPublicApiInput ? true : false, + TPublicApi extends ContentContainerPublicApiInput + ? TPath + : string +> => { + if (typeof id !== "string" || !CONTENT_ID_PATTERN.test(id)) { + throw new ContentEngineError( + `Container definition id ${JSON.stringify(id)} must look like "plugin.collection" (lowercase, dot separated).`, + ); + } + + if (typeof fields !== "object" || fields === null || Array.isArray(fields)) { + fail(id, "fields is an object of `field.*()` descriptors."); + } + + for (const [name, fieldValue] of Object.entries(fields)) { + assertContainerField(id, name, fieldValue); + } + + const resolvedPublicApi = resolvePublicApi(id, fields, publicApi); + + return { + admin: resolveAdmin(id, admin), + fields, + hierarchy: resolveHierarchy(id, hierarchy), + id, + kind: "contentContainer", + ordering: resolveOrdering(id, ordering), + publicApi: resolvedPublicApi as never, + schemas: buildSchemas(fields, resolvedPublicApi.fields), + }; +}; + +export { isContentContainerDefinition } from "./guard"; diff --git a/packages/vitnode/src/content/containers/errors.ts b/packages/vitnode/src/content/containers/errors.ts new file mode 100644 index 0000000000..e13d15cceb --- /dev/null +++ b/packages/vitnode/src/content/containers/errors.ts @@ -0,0 +1,62 @@ +import type { ContentContainerErrorCode } from "./types"; + +import { ContentEngineError } from "../errors"; + +const MESSAGES: Record = { + cycle: "A container cannot be moved inside itself or one of its descendants.", + definitionMismatch: + "The container belongs to a different container collection.", + hasChildren: + "The container still has child containers. Move or delete them first.", + hasContent: + "The container still holds content. Move or delete that content first.", + invalidInput: "The request does not describe a valid container.", + invalidMetadata: "The container's values do not match its definition.", + invalidPosition: "The position is outside the list of siblings.", + limitReached: "This collection already holds as many containers as it can.", + maxDepth: "The container would be nested deeper than this collection allows.", + notFound: "The container does not exist.", + parentNotFound: "The parent container does not exist.", + slugEmpty: "The slug has no letters or digits left once it is normalised.", + slugTaken: "Another container in this collection already uses that slug.", +}; + +const STATUS: Record = { + cycle: 400, + definitionMismatch: 400, + hasChildren: 409, + hasContent: 409, + invalidInput: 400, + invalidMetadata: 400, + invalidPosition: 400, + limitReached: 409, + maxDepth: 400, + notFound: 404, + parentNotFound: 400, + slugEmpty: 400, + slugTaken: 409, +}; + +export class ContentContainerError extends ContentEngineError { + constructor({ + code, + definitionId, + message, + }: { + code: ContentContainerErrorCode; + definitionId: string; + message?: string; + }) { + const detail = message ?? MESSAGES[code]; + super(detail, { contentTypeId: definitionId }); + + this.name = "ContentContainerError"; + this.code = code; + this.detail = detail; + this.status = STATUS[code]; + } + + readonly code: ContentContainerErrorCode; + readonly detail: string; + readonly status: 400 | 404 | 409; +} diff --git a/packages/vitnode/src/content/containers/guard.ts b/packages/vitnode/src/content/containers/guard.ts new file mode 100644 index 0000000000..cb1ec610b3 --- /dev/null +++ b/packages/vitnode/src/content/containers/guard.ts @@ -0,0 +1,8 @@ +import type { ContentContainerDefinition } from "./types"; + +export const isContentContainerDefinition = ( + value: unknown, +): value is ContentContainerDefinition => + typeof value === "object" && + value !== null && + (value as { kind?: unknown }).kind === "contentContainer"; diff --git a/packages/vitnode/src/content/containers/registry.test.ts b/packages/vitnode/src/content/containers/registry.test.ts new file mode 100644 index 0000000000..42771ded20 --- /dev/null +++ b/packages/vitnode/src/content/containers/registry.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; + +import { testCategoryContentType } from "@/tests/content-fixtures"; + +import type { AnyContentContainerDefinition } from "./types"; + +import { defineContentType } from "../define"; +import { ContentEngineError } from "../errors"; +import { field } from "../fields"; +import { defineContentContainer } from "./define"; +import { + assertContentContainerNamespaces, + assertContentContainerReferences, + validateContentContainers, +} from "./registry"; + +const boards = defineContentContainer({ + id: "test.boards", + publicApi: { path: "boards" }, +}) as AnyContentContainerDefinition; +const sections = defineContentContainer({ + id: "test.sections", +}) as AnyContentContainerDefinition; + +const threadType = (container?: AnyContentContainerDefinition) => + defineContentType({ + id: "test.thread", + tableName: "test_threads", + publication: true, + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + ...(container + ? { board: field.container({ definition: container }) } + : {}), + }, + publicApi: { path: "threads", fields: ["title", "slug"] }, + }); + +describe("validateContentContainers", () => { + it("returns every definition once, sorted by id", () => { + expect( + validateContentContainers([ + { definition: sections, pluginId: "test" }, + { definition: boards, pluginId: "test" }, + { definition: boards, pluginId: "test" }, + ]).map(({ definition }) => definition.id), + ).toEqual(["test.boards", "test.sections"]); + }); + + it.each([ + [ + "one id from two plugins", + [ + { definition: boards, pluginId: "test" }, + { definition: boards, pluginId: "other" }, + ], + /Duplicate container definition id, registered by both test -> test.boards and other -> test.boards/, + ], + [ + "two objects with one id", + [ + { definition: boards, pluginId: "test" }, + { + definition: defineContentContainer({ + admin: { path: "test/other" }, + id: "test.boards", + }), + pluginId: "test", + }, + ], + /Duplicate container definition id/, + ], + [ + "one admin path claimed twice", + [ + { definition: boards, pluginId: "test" }, + { + definition: defineContentContainer({ + admin: { path: "test/boards" }, + id: "other.boards", + }), + pluginId: "other", + }, + ], + /AdminCP path "test\/boards" is claimed by both/, + ], + [ + "an object not built with defineContentContainer", + [{ definition: { id: "test.boards" }, pluginId: "test" }], + /test registers a container that was not built with `defineContentContainer`/, + ], + [ + "a content type", + [{ definition: testCategoryContentType, pluginId: "test" }], + /not built with `defineContentContainer`/, + ], + ])("rejects %s", (_label, entries, message) => { + const run = () => validateContentContainers(entries as never); + + expect(run).toThrow(ContentEngineError); + expect(run).toThrow(message); + }); +}); + +describe("assertContentContainerNamespaces", () => { + const assert = + ( + containers: AnyContentContainerDefinition[], + contentTypes = [threadType()], + ) => + () => + assertContentContainerNamespaces({ + containers, + contentTypes, + pluginId: "test", + }); + + it("accepts containers that share nothing with the content types", () => { + expect(assert([boards, sections])).not.toThrow(); + }); + + it.each([ + [ + "the permission module of a content type", + defineContentContainer({ + admin: { permissionModule: "thread" }, + id: "test.forums", + }), + /Permission module "thread" is used by both test -> test.thread and test -> test.forums/, + ], + [ + "the permission module of another container", + defineContentContainer({ + admin: { path: "test/forums", permissionModule: "test_boards" }, + id: "test.forums", + }), + /Permission module "test_boards" is used by both test -> test.boards and test -> test.forums/, + ], + [ + "the public path of a content type", + defineContentContainer({ + id: "test.forums", + publicApi: { path: "threads" }, + }), + /Public path "threads" is claimed by both test -> test.thread and test -> test.forums/, + ], + [ + "the public path of a content type with the same id", + defineContentContainer({ + id: "test.thread", + publicApi: { path: "threads" }, + }), + /Public path "threads" is claimed by both test -> test.thread and test -> test.thread/, + ], + [ + "the public path of another container", + defineContentContainer({ + id: "test.forums", + publicApi: { path: "boards" }, + }), + /Public path "boards" is claimed by both test -> test.boards and test -> test.forums/, + ], + ])("rejects a container that takes %s", (_label, container, message) => { + const run = assert([boards, container]); + + expect(run).toThrow(ContentEngineError); + expect(run).toThrow(message); + }); +}); + +describe("assertContentContainerReferences", () => { + const registered = [{ definition: boards, pluginId: "test" }]; + + it.each([ + ["a content type without a container field", threadType()], + ["a container field naming a registered collection", threadType(boards)], + ])("accepts %s", (_label, contentType) => { + expect(() => + assertContentContainerReferences([contentType], registered), + ).not.toThrow(); + }); + + it("rejects a container field naming a collection no plugin registers", () => { + const run = () => + assertContentContainerReferences([threadType(sections)], registered); + + expect(run).toThrow(ContentEngineError); + expect(run).toThrow( + /Field "board" uses the container collection "test.sections", which no plugin registers/, + ); + }); +}); diff --git a/packages/vitnode/src/content/containers/registry.ts b/packages/vitnode/src/content/containers/registry.ts new file mode 100644 index 0000000000..50f5b33ff1 --- /dev/null +++ b/packages/vitnode/src/content/containers/registry.ts @@ -0,0 +1,145 @@ +import type { PermissionStaffEntryInput } from "../../api/lib/permission-staff"; +import type { AnyContentTypeDefinition } from "../types"; +import type { AnyContentContainerDefinition } from "./types"; + +import { CONTENT_PERMISSIONS } from "../const"; +import { ContentEngineError } from "../errors"; +import { contentContainerFieldOf } from "./association"; +import { isContentContainerDefinition } from "./guard"; + +export interface RegisteredContentContainer { + definition: AnyContentContainerDefinition; + pluginId: string; +} + +const describe = (entry: RegisteredContentContainer): string => + `${entry.pluginId} -> ${entry.definition.id}`; + +export const validateContentContainers = ( + entries: readonly RegisteredContentContainer[], +): RegisteredContentContainer[] => { + const byId = new Map(); + const byAdminPath = new Map(); + + for (const entry of entries) { + const { definition } = entry; + if (!isContentContainerDefinition(definition)) { + throw new ContentEngineError( + `${entry.pluginId} registers a container that was not built with \`defineContentContainer\`.`, + ); + } + + const duplicate = byId.get(definition.id); + if (duplicate) { + if ( + duplicate.definition === definition && + duplicate.pluginId === entry.pluginId + ) { + continue; + } + + throw new ContentEngineError( + `Duplicate container definition id, registered by both ${describe(duplicate)} and ${describe(entry)}.`, + { contentTypeId: definition.id }, + ); + } + byId.set(definition.id, entry); + + const duplicatePath = byAdminPath.get(definition.admin.path); + if (duplicatePath) { + throw new ContentEngineError( + `AdminCP path "${definition.admin.path}" is claimed by both ${describe(duplicatePath)} and ${describe(entry)}. Give one of them a different \`admin.path\`.`, + { contentTypeId: definition.id }, + ); + } + byAdminPath.set(definition.admin.path, entry); + } + + return [...byId.values()].sort((a, b) => + a.definition.id.localeCompare(b.definition.id), + ); +}; + +export const assertContentContainerNamespaces = ({ + containers, + contentTypes, + pluginId, +}: { + containers: readonly AnyContentContainerDefinition[]; + contentTypes: readonly AnyContentTypeDefinition[]; + pluginId: string; +}): void => { + const modules = new Map(); + const paths = new Map(); + const describeOwner = (owner: string): string => + `${pluginId} -> ${owner.slice(owner.indexOf(":") + 1)}`; + + for (const definition of contentTypes) { + modules.set(definition.permissionModule, `type:${definition.id}`); + if (definition.publicApi.enabled) { + paths.set(definition.publicApi.path, `type:${definition.id}`); + } + } + + for (const definition of containers) { + const self = `container:${definition.id}`; + const module = definition.admin.permissionModule; + const moduleOwner = modules.get(module); + if (moduleOwner !== undefined && moduleOwner !== self) { + throw new ContentEngineError( + `Permission module "${module}" is used by both ${describeOwner(moduleOwner)} and ${pluginId} -> ${definition.id}. Set \`admin.permissionModule\` on one of them.`, + { contentTypeId: definition.id }, + ); + } + modules.set(module, self); + + if (!definition.publicApi.enabled) continue; + const path = definition.publicApi.path; + const pathOwner = paths.get(path); + if (pathOwner !== undefined && pathOwner !== self) { + throw new ContentEngineError( + `Public path "${path}" is claimed by both ${describeOwner(pathOwner)} and ${pluginId} -> ${definition.id}. Give one of them a different \`publicApi.path\`.`, + { contentTypeId: definition.id }, + ); + } + paths.set(path, self); + } +}; + +export const assertContentContainerReferences = ( + contentTypes: readonly AnyContentTypeDefinition[], + containers: readonly RegisteredContentContainer[], +): void => { + const registered = new Map( + containers.map(entry => [entry.definition.id, entry.definition]), + ); + + for (const definition of contentTypes) { + const container = contentContainerFieldOf(definition); + if (!container) continue; + + if (!registered.has(container.field.definition.id)) { + throw new ContentEngineError( + `Field "${container.name}" uses the container collection "${container.field.definition.id}", which no plugin registers. Pass it to \`buildContentAdminModule({ containers: [...] })\`.`, + { contentTypeId: definition.id }, + ); + } + } +}; + +export const contentContainerPermissionEntries = + (): PermissionStaffEntryInput[] => [ + CONTENT_PERMISSIONS.view, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.create, + }, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.edit, + }, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.delete, + }, + ]; diff --git a/packages/vitnode/src/content/containers/server/admin-routes.test.ts b/packages/vitnode/src/content/containers/server/admin-routes.test.ts new file mode 100644 index 0000000000..9c6ba8d1c9 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/admin-routes.test.ts @@ -0,0 +1,292 @@ +import type { Context, MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { core_content_containers } from "@/database/content-containers"; +import { createTestCache } from "@/tests/cache"; +import { testBoardContainers } from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import type { ContentContainerUsageCheck } from "./service"; + +import { CONTENT_PERMISSIONS } from "../../const"; +import { buildContentContainerAdminRoutes } from "./admin-routes"; + +const PLUGIN_ID = "@vitnode/example"; +const OTHER_PLUGIN_ID = "@vitnode/other"; +const MODULE = testBoardContainers.admin.permissionModule; +const ALL_PERMISSIONS = Object.values(CONTENT_PERMISSIONS); +const CREATED = new Date("2026-09-01T10:00:00.000Z"); + +const BOARD = { CHILD: 2, ELSEWHERE: 9, FULL: 3, MISSING: 99, ROOT: 1 }; + +const adminUser = { email: "test@test.com", id: 1, name: "Test", roleId: 1 }; + +const row = ( + id: number, + slug: string, + parentId: null | number, + position: number, + definitionId: string = testBoardContainers.id, +) => ({ + createdAt: CREATED, + definitionId, + id, + metadata: {}, + name: slug, + parentId, + pluginId: PLUGIN_ID, + position, + slug, + updatedAt: CREATED, +}); + +const holdsContent: ContentContainerUsageCheck = async (_tx, ids) => + await Promise.resolve(ids.includes(BOARD.FULL)); + +const routes = buildContentContainerAdminRoutes(testBoardContainers, { + pluginId: PLUGIN_ID, + usage: holdsContent, +}); + +const harness = async ( + permissions: + | PermissionsStaffArgs[] + | typeof ROOT_STAFF_PERMISSIONS = ROOT_STAFF_PERMISSIONS, +) => { + const memory = createMemoryDb([ + [ + core_content_containers, + [ + row(BOARD.ROOT, "root", null, 0), + row(BOARD.CHILD, "child", BOARD.ROOT, 0), + row(BOARD.FULL, "full", null, 1), + row(BOARD.ELSEWHERE, "elsewhere", null, 0, "test.sections"), + ], + ], + ]); + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: adminUser.id }); + + const app = new OpenAPIHono(); + const context: MiddlewareHandler = async (c, next) => { + c.set("admin", { user: adminUser } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { + emit: async () => await Promise.resolve({ failures: [] }), + } as unknown as Context["var"]["events"]); + await next(); + }; + app.use("*", context); + app.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + for (const { handler, hook, route } of routes) { + app.openapi(route, handler, hook as never); + } + + const send = async (method: string, path: string, body?: unknown) => + await app.request(path, { + method, + ...(body === undefined + ? {} + : { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }), + }); + + return { rows: () => memory.rows(core_content_containers), send }; +}; + +describe("status mapping", () => { + it("lists one collection in tree order", async () => { + const { send } = await harness(); + + const response = await send("GET", "/"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { items: { id: number }[] }; + expect(body.items.map(item => item.id)).toEqual([ + BOARD.ROOT, + BOARD.CHILD, + BOARD.FULL, + ]); + }); + + it.each([ + ["POST", "/", { name: "New board" }, 201], + ["PATCH", `/${BOARD.CHILD}`, { name: "Renamed" }, 200], + ["POST", `/${BOARD.CHILD}/move`, { parentId: null, position: 0 }, 200], + ["DELETE", `/${BOARD.CHILD}`, undefined, 204], + ])("answers %s %s with %i", async (method, path, body, status) => { + const { send } = await harness(); + + expect((await send(method, path, body)).status).toBe(status); + }); + + it("returns the created container", async () => { + const { rows, send } = await harness(); + + const response = await send("POST", "/", { + metadata: { color: "#000000" }, + name: "New board", + parentId: BOARD.ROOT, + }); + + const created = (await response.json()) as { id: number }; + expect(created).toMatchObject({ + depth: 2, + metadata: { color: "#000000", locked: false }, + parentId: BOARD.ROOT, + position: 1, + slug: "new-board", + }); + expect(rows().some(candidate => candidate.id === created.id)).toBe(true); + }); + + it.each([ + ["POST", "/", { name: "Taken", slug: "root" }, 409, "slugTaken"], + [ + "POST", + "/", + { metadata: { color: "#1234567" }, name: "Loud" }, + 400, + "invalidMetadata", + ], + ["POST", "/", { name: "" }, 400, "invalidInput"], + [ + "PATCH", + `/${BOARD.CHILD}`, + { metadata: { unknown: true } }, + 400, + "invalidMetadata", + ], + [ + "POST", + `/${BOARD.CHILD}/move`, + { parentId: null, position: -1 }, + 400, + "invalidInput", + ], + [ + "POST", + "/", + { name: "Lost", parentId: BOARD.ELSEWHERE }, + 400, + "parentNotFound", + ], + ["PATCH", `/${BOARD.MISSING}`, { name: "Gone" }, 404, "notFound"], + ["PATCH", `/${BOARD.ELSEWHERE}`, { name: "Not mine" }, 404, "notFound"], + ["PATCH", `/${BOARD.CHILD}`, { slug: "root" }, 409, "slugTaken"], + [ + "POST", + `/${BOARD.ROOT}/move`, + { parentId: BOARD.CHILD, position: 0 }, + 400, + "cycle", + ], + [ + "POST", + `/${BOARD.MISSING}/move`, + { parentId: null, position: 0 }, + 404, + "notFound", + ], + ["DELETE", `/${BOARD.ROOT}`, undefined, 409, "hasChildren"], + ["DELETE", `/${BOARD.FULL}`, undefined, 409, "hasContent"], + ["DELETE", `/${BOARD.MISSING}`, undefined, 404, "notFound"], + ])( + "refuses %s %s with %i and a code", + async (method, path, body, status, code) => { + const { send } = await harness(); + + const response = await send(method, path, body); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ + code, + message: expect.any(String), + }); + }, + ); +}); + +describe("permission matrix", () => { + const DOCUMENTED: Record = { + "DELETE /{id}": CONTENT_PERMISSIONS.delete, + "GET /": CONTENT_PERMISSIONS.view, + "PATCH /{id}": CONTENT_PERMISSIONS.edit, + "POST /": CONTENT_PERMISSIONS.create, + "POST /{id}/move": CONTENT_PERMISSIONS.edit, + }; + + const REQUESTS: Record = { + "DELETE /{id}": ["DELETE", `/${BOARD.CHILD}`, undefined], + "GET /": ["GET", "/", undefined], + "PATCH /{id}": ["PATCH", `/${BOARD.CHILD}`, { name: "Renamed" }], + "POST /": ["POST", "/", { name: "New board" }], + "POST /{id}/move": [ + "POST", + `/${BOARD.CHILD}/move`, + { parentId: null, position: 0 }, + ], + }; + + const grantOf = ( + permissions: readonly string[], + plugin = PLUGIN_ID, + ): PermissionsStaffArgs[] => + permissions.map(permission => ({ module: MODULE, permission, plugin })); + + const statusWith = async (label: string, grant: PermissionsStaffArgs[]) => { + const { send } = await harness(grant); + const [method, path, body] = REQUESTS[label]; + + return (await send(method, path, body)).status; + }; + + it("documents a permission for exactly the routes the builder produced", () => { + expect( + routes + .map(({ route }) => `${route.method.toUpperCase()} ${route.path}`) + .sort(), + ).toEqual(Object.keys(DOCUMENTED).sort()); + }); + + it.each(Object.entries(DOCUMENTED))( + "%s is refused without %s and allowed with only it", + async (label, permission) => { + await expect(statusWith(label, [])).resolves.toBe(403); + await expect( + statusWith( + label, + grantOf(ALL_PERMISSIONS.filter(other => other !== permission)), + ), + ).resolves.toBe(403); + await expect( + statusWith(label, grantOf([permission])), + ).resolves.toBeLessThan(300); + }, + ); + + it.each(Object.entries(DOCUMENTED))( + "%s is refused when %s was granted for another plugin", + async (label, permission) => { + await expect( + statusWith(label, grantOf([permission], OTHER_PLUGIN_ID)), + ).resolves.toBe(403); + }, + ); +}); diff --git a/packages/vitnode/src/content/containers/server/admin-routes.ts b/packages/vitnode/src/content/containers/server/admin-routes.ts new file mode 100644 index 0000000000..57744dee97 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/admin-routes.ts @@ -0,0 +1,233 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { RouteValidationHook } from "../../../api/lib/route"; +import type { + AnyContentContainerDefinition, + ContentContainerCreateInput, + ContentContainerMoveInput, + ContentContainerRecord, + ContentContainerUpdateInput, +} from "../types"; +import type { ContentContainerUsageCheck } from "./service"; + +import { buildRoute } from "../../../api/lib/route"; +import { CONTENT_PERMISSIONS } from "../../const"; +import { CONTENT_CONTAINER_ERROR_CODES } from "../const"; +import { ContentContainerError } from "../errors"; +import { + contentContainerInputRefusal, + createContentContainerService, +} from "./service"; + +export const zodContentContainerError = z.object({ + code: z.enum(CONTENT_CONTAINER_ERROR_CODES), + message: z.string(), +}); + +const containerError = (error: unknown): never => { + if (error instanceof ContentContainerError) { + throw new HTTPException(error.status, { + res: Response.json( + { code: error.code, message: error.detail }, + { status: error.status }, + ), + }); + } + + throw error; +}; + +const validationHook: RouteValidationHook = (result, c) => { + if (result.success) return undefined; + + return c.json(contentContainerInputRefusal(result.error), 400); +}; + +const idParams = z.object({ + id: z.coerce.number().int().positive(), +}); + +const errorResponse = (description: string) => ({ + content: { "application/json": { schema: zodContentContainerError } }, + description, +}); + +export const buildContentContainerAdminRoutes = < + TDefinition extends AnyContentContainerDefinition, + P extends string, +>( + definition: TDefinition, + { pluginId, usage }: { pluginId: P; usage?: ContentContainerUsageCheck }, +) => { + const module = definition.admin.permissionModule; + const schemas = definition.schemas as unknown as { + create: z.ZodType>; + move: z.ZodType; + record: z.ZodType>; + update: z.ZodType>; + }; + const service = (c: Context) => + createContentContainerService(c, { definition, pluginId, usage }); + + const list = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/", + description: `List every container of ${definition.id}, in tree order (Admin only)`, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ items: z.array(schemas.record) }), + }, + }, + description: "Every container, each parent followed by its subtree", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => c.json({ items: await service(c).list() }, 200), + }); + + const create = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.create }, + route: { + method: "post", + path: "/", + description: `Create a container in ${definition.id} (Admin only)`, + request: { + body: { + required: true, + content: { "application/json": { schema: schemas.create } }, + }, + }, + responses: { + 201: { + content: { "application/json": { schema: schemas.record } }, + description: "Container created", + }, + 400: errorResponse("The tree rules or the definition refuse it"), + 403: { description: "Access Denied" }, + 409: errorResponse("The slug is taken, or the collection is full"), + }, + }, + handler: async c => { + try { + return c.json(await service(c).create(c.req.valid("json")), 201); + } catch (error) { + return containerError(error); + } + }, + }); + + const update = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "patch", + path: "/{id}", + description: `Update a container's name, slug or values in ${definition.id} (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { "application/json": { schema: schemas.update } }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: schemas.record } }, + description: "Container updated", + }, + 400: errorResponse("The values do not match the definition"), + 403: { description: "Access Denied" }, + 404: errorResponse("No such container in this collection"), + 409: errorResponse("The slug is taken"), + }, + }, + handler: async c => { + try { + const { id } = c.req.valid("param"); + + return c.json(await service(c).update(id, c.req.valid("json")), 200); + } catch (error) { + return containerError(error); + } + }, + }); + + const move = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "post", + path: "/{id}/move", + description: `Move a container to another parent, another position among its siblings, or both, in ${definition.id} (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { "application/json": { schema: schemas.move } }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: schemas.record } }, + description: "Container moved", + }, + 400: errorResponse( + "A cycle, a missing parent, a position out of range or too deep", + ), + 403: { description: "Access Denied" }, + 404: errorResponse("No such container in this collection"), + }, + }, + handler: async c => { + try { + const { id } = c.req.valid("param"); + + return c.json(await service(c).move(id, c.req.valid("json")), 200); + } catch (error) { + return containerError(error); + } + }, + }); + + const remove = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}", + description: `Delete an empty container from ${definition.id} (Admin only)`, + request: { params: idParams }, + responses: { + 204: { description: "Container deleted" }, + 403: { description: "Access Denied" }, + 404: errorResponse("No such container in this collection"), + 409: errorResponse("The container still has children or content"), + }, + }, + handler: async c => { + try { + await service(c).delete(c.req.valid("param").id); + + return c.body(null, 204); + } catch (error) { + return containerError(error); + } + }, + }); + + return [list, create, update, move, remove]; +}; diff --git a/packages/vitnode/src/content/containers/server/assignment.test.ts b/packages/vitnode/src/content/containers/server/assignment.test.ts new file mode 100644 index 0000000000..6a565ce55d --- /dev/null +++ b/packages/vitnode/src/content/containers/server/assignment.test.ts @@ -0,0 +1,246 @@ +import type { Context, MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; + +import { core_content_containers } from "@/database/content-containers"; +import { createTestCache } from "@/tests/cache"; +import { + testCategoryContentType, + testThreadContentType, +} from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import type { ContentDatabase } from "../../server/service"; + +import { createContentModel } from "../../server/model"; +import { buildContentRoutes } from "../../server/routes"; +import { ContentContainerAssignmentError } from "../association"; +import { resolveContentContainerAssignment } from "./assignment"; + +const PLUGIN_ID = "@vitnode/example"; +const CREATED = new Date("2026-09-01T10:00:00.000Z"); +const BOARD = { ELSEWHERE: 9, GENERAL: 1, HELP: 2, MISSING: 99 } as const; +const THREAD = 7; + +const threads = createContentModel(testThreadContentType); + +const container = ( + id: number, + slug: string, + definitionId: string, + parentId: null | number = null, +) => ({ + createdAt: CREATED, + definitionId, + id, + metadata: {}, + name: slug, + parentId, + pluginId: PLUGIN_ID, + position: 0, + slug, + updatedAt: CREATED, +}); + +const seed = () => + createMemoryDb([ + [ + core_content_containers, + [ + container(BOARD.GENERAL, "general", "test.boards"), + container(BOARD.HELP, "help", "test.boards", BOARD.GENERAL), + container(BOARD.ELSEWHERE, "elsewhere", "test.sections"), + ], + ], + [ + threads.table, + [ + { + author: 1, + board: BOARD.GENERAL, + createdAt: CREATED, + id: THREAD, + publishedAt: null, + slug: "hello", + status: "draft", + title: "Hello", + updatedAt: CREATED, + }, + ], + ], + ]); + +const contextOf = (memory: ReturnType) => + ({ + get: (key: string) => + key === "db" + ? memory.db + : key === "events" + ? { emit: async () => await Promise.resolve({ failures: [] }) } + : undefined, + }) as unknown as Context; + +const failureOf = async (run: () => Promise) => + await run().then( + () => null, + (error: unknown) => error, + ); + +describe("resolveContentContainerAssignment", () => { + const resolve = async (values: Record) => + await resolveContentContainerAssignment( + seed().db as unknown as ContentDatabase, + testThreadContentType, + values, + ); + + it("returns the container the write names, read back from the database", async () => { + await expect(resolve({ board: BOARD.HELP })).resolves.toEqual({ + definitionId: "test.boards", + id: BOARD.HELP, + parentId: BOARD.GENERAL, + pluginId: PLUGIN_ID, + slug: "help", + }); + }); + + it.each([ + ["a write that does not touch the container", {}, undefined], + ["a write that clears it", { board: null }, null], + ])("passes %s through", async (_label, values, expected) => { + await expect(resolve(values)).resolves.toBe(expected); + }); + + it("ignores a content type without a container field", async () => { + await expect( + resolveContentContainerAssignment( + seed().db as unknown as ContentDatabase, + testCategoryContentType, + { board: BOARD.MISSING }, + ), + ).resolves.toBeUndefined(); + }); + + it.each([ + ["a container that does not exist", BOARD.MISSING, "CONTAINER_NOT_FOUND"], + ["a zero id", 0, "CONTAINER_NOT_FOUND"], + ["an id that is not a number", String(BOARD.HELP), "CONTAINER_NOT_FOUND"], + [ + "another collection's container", + BOARD.ELSEWHERE, + "CONTAINER_DEFINITION_MISMATCH", + ], + ])("refuses %s and names the field", async (_label, board, code) => { + const error = await failureOf(async () => await resolve({ board })); + + expect(error).toBeInstanceOf(ContentContainerAssignmentError); + expect(error).toMatchObject({ code, field: "board" }); + }); +}); + +describe("the content service checks the container on every write", () => { + it("creates a record inside a container of its collection", async () => { + const memory = seed(); + + const created = await threads + .service(contextOf(memory)) + .create({ author: 1, board: BOARD.HELP, title: "Second thread" }); + + expect( + memory.rows(threads.table).find(row => row.id === created.id), + ).toMatchObject({ board: BOARD.HELP }); + }); + + it("moves a record into another container of its collection", async () => { + const memory = seed(); + + await threads + .service(contextOf(memory)) + .update(THREAD, { board: BOARD.HELP }); + + expect( + memory.rows(threads.table).find(row => row.id === THREAD), + ).toMatchObject({ board: BOARD.HELP }); + }); + + it.each([ + ["create", BOARD.MISSING, "CONTAINER_NOT_FOUND"], + ["create", BOARD.ELSEWHERE, "CONTAINER_DEFINITION_MISMATCH"], + ["update", BOARD.MISSING, "CONTAINER_NOT_FOUND"], + ["update", BOARD.ELSEWHERE, "CONTAINER_DEFINITION_MISMATCH"], + ])( + "refuses to %s a record in container %i and writes nothing", + async (operation, board, code) => { + const memory = seed(); + const service = threads.service(contextOf(memory)); + const before = memory.rows(threads.table); + + const error = await failureOf(async () => + operation === "create" + ? await service.create({ author: 1, board, title: "Lost thread" }) + : await service.update(THREAD, { board }), + ); + + expect(error).toMatchObject({ code, field: "board" }); + expect(memory.rows(threads.table)).toEqual(before); + }, + ); +}); + +describe("the generated admin routes", () => { + it("answer a container of another collection with 400 naming the field", async () => { + const memory = seed(); + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: 1, + }); + + const app = new OpenAPIHono(); + const context: MiddlewareHandler = async (c, next) => { + c.set("admin", { + user: { id: 1, roleId: 1 }, + } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { + emit: async () => await Promise.resolve({ failures: [] }), + } as unknown as Context["var"]["events"]); + await next(); + }; + app.use("*", context); + app.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + for (const { handler, route } of buildContentRoutes(threads, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + const response = await app.request("/", { + body: JSON.stringify({ + author: 1, + board: BOARD.ELSEWHERE, + title: "Lost thread", + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + code: "CONTAINER_DEFINITION_MISMATCH", + field: "board", + message: expect.any(String), + }); + }); +}); diff --git a/packages/vitnode/src/content/containers/server/assignment.ts b/packages/vitnode/src/content/containers/server/assignment.ts new file mode 100644 index 0000000000..8b362e7b6a --- /dev/null +++ b/packages/vitnode/src/content/containers/server/assignment.ts @@ -0,0 +1,64 @@ +import { eq } from "drizzle-orm"; + +import type { ContentDatabase } from "../../server/service"; +import type { AnyContentTypeDefinition } from "../../types"; + +import { core_content_containers } from "../../../database/content-containers"; +import { + ContentContainerAssignmentError, + contentContainerFieldOf, +} from "../association"; + +export interface ResolvedContentContainerContext { + definitionId: string; + id: number; + parentId: null | number; + pluginId: string; + slug: string; +} + +export const resolveContentContainerAssignment = async ( + tx: ContentDatabase, + definition: AnyContentTypeDefinition, + values: Record, +): Promise => { + const container = contentContainerFieldOf(definition); + if (!container) return undefined; + + const value = values[container.name]; + if (value === undefined) return undefined; + if (value === null) return null; + + const refuse = ( + code: ContentContainerAssignmentError["code"], + ): ContentContainerAssignmentError => + new ContentContainerAssignmentError({ + code, + contentTypeId: definition.id, + field: container.name, + }); + + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw refuse("CONTAINER_NOT_FOUND"); + } + + const [row] = await tx + .select({ + definitionId: core_content_containers.definitionId, + id: core_content_containers.id, + parentId: core_content_containers.parentId, + pluginId: core_content_containers.pluginId, + slug: core_content_containers.slug, + }) + .from(core_content_containers) + .where(eq(core_content_containers.id, value)) + .limit(1) + .for("share"); + + if (!row) throw refuse("CONTAINER_NOT_FOUND"); + if (row.definitionId !== container.field.definition.id) { + throw refuse("CONTAINER_DEFINITION_MISMATCH"); + } + + return row; +}; diff --git a/packages/vitnode/src/content/containers/server/public-routes.test.ts b/packages/vitnode/src/content/containers/server/public-routes.test.ts new file mode 100644 index 0000000000..c0a95b43c0 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/public-routes.test.ts @@ -0,0 +1,187 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; + +import { core_content_containers } from "@/database/content-containers"; +import { testBoardContainers } from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; + +import { buildContentContainerPublicRoutes } from "./public-routes"; + +const PLUGIN_ID = "@vitnode/example"; +const CREATED = new Date("2026-09-01T10:00:00.000Z"); + +const BOARD = { + ELSEWHERE: 9, + GENERAL: 1, + HELP: 4, + MISSING: 99, + NEWS: 3, + OFF_TOPIC: 2, + RELEASES: 5, +} as const; + +const PRIVATE = { locked: true, notes: "Staff only" }; + +const row = ( + id: number, + slug: string, + parentId: null | number, + position: number, + definitionId: string = testBoardContainers.id, +) => ({ + createdAt: CREATED, + definitionId, + id, + metadata: { color: "#ffffff", description: `About ${slug}`, ...PRIVATE }, + name: slug, + parentId, + pluginId: PLUGIN_ID, + position, + slug, + updatedAt: CREATED, +}); + +const SEED = [ + row(BOARD.OFF_TOPIC, "off-topic", null, 1), + row(BOARD.GENERAL, "general", null, 0), + row(BOARD.HELP, "help", BOARD.GENERAL, 1), + row(BOARD.NEWS, "news", BOARD.GENERAL, 0), + row(BOARD.RELEASES, "releases", BOARD.NEWS, 0), + row(BOARD.ELSEWHERE, "elsewhere", null, 0, "test.sections"), +]; + +interface PublicContainer { + depth: number; + id: number; + metadata: Record; +} + +const harness = (seed: readonly object[] = SEED) => { + const memory = createMemoryDb([[core_content_containers, seed]]); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("db", memory.db as unknown as Context["var"]["db"]); + await next(); + }); + app.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + for (const { handler, route } of buildContentContainerPublicRoutes( + testBoardContainers, + { pluginId: PLUGIN_ID }, + )) { + app.openapi(route, handler); + } + + return app; +}; + +const ids = (items: readonly PublicContainer[]) => items.map(item => item.id); + +const list = async (query = "") => { + const response = await harness().request(`/${query}`); + const body = (await response.json()) as { items: PublicContainer[] }; + + return { body, status: response.status }; +}; + +describe("GET /", () => { + it("lists the collection in tree order, with depths", async () => { + const { body, status } = await list(); + + expect(status).toBe(200); + expect(body.items.map(item => [item.id, item.depth])).toEqual([ + [BOARD.GENERAL, 1], + [BOARD.NEWS, 2], + [BOARD.RELEASES, 3], + [BOARD.HELP, 2], + [BOARD.OFF_TOPIC, 1], + ]); + }); + + it.each([ + ["root", [BOARD.GENERAL, BOARD.OFF_TOPIC]], + [String(BOARD.GENERAL), [BOARD.NEWS, BOARD.HELP]], + [String(BOARD.RELEASES), []], + ])( + "narrows the list to one level with parent=%s", + async (parent, expected) => { + const { body, status } = await list(`?parent=${parent}`); + + expect(status).toBe(200); + expect(ids(body.items)).toEqual(expected); + }, + ); + + it.each([BOARD.MISSING, BOARD.ELSEWHERE])( + "is a 404 for parent=%i, which is not in the collection", + async parent => { + expect((await harness().request(`/?parent=${parent}`)).status).toBe(404); + }, + ); + + it("answers an empty collection with no items", async () => { + const response = await harness([]).request("/"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ items: [] }); + }); +}); + +describe("GET /{idOrSlug}", () => { + it.each([ + ["id", String(BOARD.NEWS)], + ["slug", "news"], + ])( + "returns a container by %s, with its ancestors and children", + async (_label, segment) => { + const response = await harness().request(`/${segment}`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + ancestors: PublicContainer[]; + children: PublicContainer[]; + container: PublicContainer; + }; + expect(body.container).toEqual({ + depth: 2, + id: BOARD.NEWS, + metadata: { color: "#ffffff", description: "About news" }, + name: "news", + parentId: BOARD.GENERAL, + position: 0, + slug: "news", + }); + expect(ids(body.children)).toEqual([BOARD.RELEASES]); + expect(ids(body.ancestors)).toEqual([BOARD.GENERAL]); + }, + ); + + it.each([ + String(BOARD.MISSING), + "nope", + String(BOARD.ELSEWHERE), + "elsewhere", + ])("is a 404 for %s", async segment => { + expect((await harness().request(`/${segment}`)).status).toBe(404); + }); +}); + +describe("private metadata", () => { + it.each(["/", "/?parent=root", `/${BOARD.NEWS}`, "/news"])( + "never leaves GET %s", + async path => { + const text = await (await harness().request(path)).text(); + + expect(text).not.toContain("locked"); + expect(text).not.toContain("notes"); + expect(text).not.toContain("Staff only"); + expect(text).not.toContain("createdAt"); + }, + ); +}); diff --git a/packages/vitnode/src/content/containers/server/public-routes.ts b/packages/vitnode/src/content/containers/server/public-routes.ts new file mode 100644 index 0000000000..6ed538bbe4 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/public-routes.ts @@ -0,0 +1,134 @@ +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { + AnyContentContainerDefinition, + ContentContainerPublicRecord, +} from "../types"; + +import { setPageCacheTags } from "../../../api/lib/page-cache"; +import { buildRoute } from "../../../api/lib/route"; +import { contentContainerTag } from "../../cache"; +import { CONTENT_CONTAINER_SLUG_MAX_LENGTH } from "../const"; +import { contentContainerAncestors } from "../tree"; +import { createContentContainerService } from "./service"; + +const NUMERIC_ID = /^[1-9][0-9]{0,15}$/; + +export const buildContentContainerPublicRoutes = < + TDefinition extends AnyContentContainerDefinition, + P extends string, +>( + definition: TDefinition, + { pluginId }: { pluginId: P }, +) => { + const record = definition.schemas.publicRecord as unknown as z.ZodType< + ContentContainerPublicRecord + >; + const service = (c: Parameters[0]) => + createContentContainerService(c, { definition, pluginId }); + + const list = buildRoute({ + pluginId, + route: { + method: "get", + path: "/", + description: `List the containers of ${definition.id}, in tree order. \`parent=root\` or \`parent={id}\` narrows the list to one level.`, + request: { + query: z.object({ + parent: z + .union([z.literal("root"), z.coerce.number().int().positive()]) + .optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ items: z.array(record) }), + }, + }, + description: + "Containers, each parent followed by its subtree - nest them with `contentContainerTree`", + }, + 404: { description: "The parent does not exist" }, + }, + }, + handler: async c => { + const { parent } = c.req.valid("query"); + const containers = service(c); + const all = await containers.list(); + let items = all; + + if (parent === "root") { + items = all.filter(item => item.parentId === null); + } else if (parent !== undefined) { + if (!all.some(item => item.id === parent)) { + throw new HTTPException(404, { message: "Container not found." }); + } + items = all.filter(item => item.parentId === parent); + } + + setPageCacheTags(c, [contentContainerTag(definition.id)]); + + return c.json({ items: items.map(containers.toPublic) }, 200); + }, + }); + + const detail = buildRoute({ + pluginId, + route: { + method: "get", + path: "/{idOrSlug}", + description: `One container of ${definition.id} by id or slug, with its ancestors (root first) and its direct children`, + request: { + params: z.object({ + idOrSlug: z.string().min(1).max(CONTENT_CONTAINER_SLUG_MAX_LENGTH), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + ancestors: z.array(record), + children: z.array(record), + container: record, + }), + }, + }, + description: "The container, its breadcrumb and its children", + }, + 404: { description: "Container not found" }, + }, + }, + handler: async c => { + const { idOrSlug } = c.req.valid("param"); + const containers = service(c); + const all = await containers.list(); + const container = NUMERIC_ID.test(idOrSlug) + ? all.find(item => item.id === Number(idOrSlug)) + : all.find(item => item.slug === idOrSlug); + if (!container) { + throw new HTTPException(404, { message: "Container not found." }); + } + + const ancestors = contentContainerAncestors(all, container.id); + + setPageCacheTags(c, [contentContainerTag(definition.id)]); + + return c.json( + { + ancestors: ancestors.map(containers.toPublic), + children: all + .filter(item => item.parentId === container.id) + .map(containers.toPublic), + container: containers.toPublic(container), + }, + 200, + ); + }, + }); + + return [list, detail]; +}; diff --git a/packages/vitnode/src/content/containers/server/service.test.ts b/packages/vitnode/src/content/containers/server/service.test.ts new file mode 100644 index 0000000000..b8e1a1a347 --- /dev/null +++ b/packages/vitnode/src/content/containers/server/service.test.ts @@ -0,0 +1,662 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import { core_content_containers } from "@/database/content-containers"; +import { + testBoardContainers, + testThreadContentType, +} from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { + ContentContainerService, + ContentContainerUsageCheck, +} from "./service"; + +import { createContentModel } from "../../server/model"; +import { ContentContainerError } from "../errors"; +import { createContentContainerService } from "./service"; + +const PLUGIN_ID = "@vitnode/example"; +const CREATED = new Date("2026-09-01T10:00:00.000Z"); + +const BOARD = { + BUGS: 6, + ELSEWHERE: 9, + GENERAL: 1, + HELP: 4, + MISSING: 99, + NEWS: 3, + OFF_TOPIC: 2, + RELEASES: 5, +} as const; + +const row = ( + id: number, + name: string, + parentId: null | number, + position: number, + overrides: Record = {}, +) => ({ + createdAt: CREATED, + definitionId: testBoardContainers.id, + id, + metadata: {}, + name, + parentId, + pluginId: PLUGIN_ID, + position, + slug: name.toLowerCase().replace(/ /g, "-"), + updatedAt: CREATED, + ...overrides, +}); + +const seed = () => [ + row(BOARD.GENERAL, "General", null, 0, { + metadata: { + color: "#ffffff", + description: "Say hi", + locked: true, + notes: "Staff only", + }, + }), + row(BOARD.OFF_TOPIC, "Off topic", null, 1), + row(BOARD.NEWS, "News", BOARD.GENERAL, 0), + row(BOARD.HELP, "Help", BOARD.GENERAL, 1), + row(BOARD.RELEASES, "Releases", BOARD.NEWS, 0), + row(BOARD.BUGS, "Bugs", BOARD.GENERAL, 2), + row(BOARD.ELSEWHERE, "Elsewhere", null, 0, { + definitionId: "test.sections", + }), +]; + +const holdsContent: ContentContainerUsageCheck = async (_tx, ids) => + await Promise.resolve(ids.includes(BOARD.OFF_TOPIC)); + +const harness = ({ + core, + usage = holdsContent, +}: { + core?: unknown; + usage?: ContentContainerUsageCheck | null; +} = {}) => { + const memory = createMemoryDb([[core_content_containers, seed()]]); + const emitted: { name: string; payload: unknown }[] = []; + const variables: Record = { + core, + db: memory.db, + events: { + emit: async (name: string, payload: unknown) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ failures: [] }); + }, + }, + }; + const c = { get: (key: string) => variables[key] } as unknown as Context; + const service = createContentContainerService(c, { + definition: testBoardContainers, + pluginId: PLUGIN_ID, + usage: usage ?? undefined, + }); + const rows = () => memory.rows(core_content_containers); + const placement = (id: number) => { + const found = rows().find(candidate => candidate.id === id); + + return found && { parentId: found.parentId, position: found.position }; + }; + + return { emitted, memory, placement, rows, service }; +}; + +const event = (name: string, payload: Record) => ({ + name, + payload: { + definitionId: testBoardContainers.id, + pluginId: PLUGIN_ID, + ...payload, + }, +}); + +describe("create", () => { + it("adds a root last, with a slug derived from the name", async () => { + const { emitted, rows, service } = harness(); + + const created = await service.create({ name: "Hello World!" }); + + expect(created).toMatchObject({ + depth: 1, + name: "Hello World!", + parentId: null, + position: 2, + slug: "hello-world", + metadata: { color: null, description: null, locked: false, notes: null }, + }); + expect(rows().find(candidate => candidate.id === created.id)).toMatchObject( + { + definitionId: testBoardContainers.id, + pluginId: PLUGIN_ID, + }, + ); + expect(emitted).toEqual([ + event("content.container.created", { + containerId: created.id, + parentId: null, + }), + ]); + }); + + it("adds a child last among its siblings", async () => { + const { service } = harness(); + + await expect( + service.create({ name: "Ideas", parentId: BOARD.GENERAL }), + ).resolves.toMatchObject({ + depth: 2, + parentId: BOARD.GENERAL, + position: 3, + }); + }); + + it("inserts at a position and shifts the siblings after it", async () => { + const { placement, service } = harness(); + + const created = await service.create({ name: "Welcome", position: 0 }); + + expect(created.position).toBe(0); + expect( + [BOARD.GENERAL, BOARD.OFF_TOPIC, BOARD.ELSEWHERE].map(placement), + ).toEqual([ + { parentId: null, position: 1 }, + { parentId: null, position: 2 }, + { parentId: null, position: 0 }, + ]); + }); + + it.each([ + [ + "suffixes a derived slug the collection already uses", + { name: "General" }, + "general-2", + ], + [ + "ignores a slug another collection uses", + { name: "Elsewhere" }, + "elsewhere", + ], + ["falls back when the name has no letters", { name: "!!!" }, "container"], + [ + "normalises an explicit slug", + { name: "Anything", slug: "Café Corner" }, + "cafe-corner", + ], + ])("%s", async (_label, input, slug) => { + const { service } = harness(); + + await expect(service.create(input)).resolves.toMatchObject({ slug }); + }); + + it("keeps suffixing until the slug is free", async () => { + const { service } = harness(); + + await service.create({ name: "General" }); + + await expect(service.create({ name: "General" })).resolves.toMatchObject({ + slug: "general-3", + }); + }); + + it("serializes concurrent creates, so each one plans against the other", async () => { + const { service } = harness(); + + const created = await Promise.all([ + service.create({ name: "Twin" }), + service.create({ name: "Twin" }), + ]); + + expect(created.map(({ position, slug }) => ({ position, slug }))).toEqual([ + { position: 2, slug: "twin" }, + { position: 3, slug: "twin-2" }, + ]); + }); +}); + +describe("update", () => { + it("merges a metadata patch into the stored values and announces the change", async () => { + const { emitted, rows, service } = harness(); + + const updated = await service.update(BOARD.GENERAL, { + metadata: { color: "#000000" }, + name: "Lobby", + }); + + expect(updated).toMatchObject({ + metadata: { + color: "#000000", + description: "Say hi", + locked: true, + notes: "Staff only", + }, + name: "Lobby", + slug: "general", + }); + expect( + rows().find(candidate => candidate.id === BOARD.GENERAL), + ).toMatchObject({ + metadata: updated.metadata, + name: "Lobby", + }); + expect(emitted).toEqual([ + event("content.container.updated", { + changedFields: ["name", "color"], + containerId: BOARD.GENERAL, + parentId: null, + }), + ]); + }); + + it("changes the slug only when one is sent", async () => { + const { service } = harness(); + + await expect( + service.update(BOARD.HELP, { slug: "Support Desk" }), + ).resolves.toMatchObject({ name: "Help", slug: "support-desk" }); + }); + + it("keeps its own slug without calling it taken", async () => { + const { emitted, service } = harness(); + + await expect( + service.update(BOARD.HELP, { slug: "help" }), + ).resolves.toMatchObject({ slug: "help" }); + expect(emitted).toEqual([]); + }); + + it("writes and announces nothing when nothing changed", async () => { + const { emitted, rows, service } = harness(); + const before = rows(); + + await service.update(BOARD.GENERAL, { + metadata: { color: "#ffffff" }, + name: "General", + }); + + expect(rows()).toEqual(before); + expect(emitted).toEqual([]); + }); +}); + +describe("move", () => { + it("moves a subtree to another parent and closes the gap it left", async () => { + const { emitted, service } = harness(); + + const moved = await service.move(BOARD.NEWS, { + parentId: BOARD.OFF_TOPIC, + position: 0, + }); + + expect(moved).toMatchObject({ + depth: 2, + parentId: BOARD.OFF_TOPIC, + position: 0, + }); + expect( + (await service.list()).map(({ depth, id, position }) => [ + id, + depth, + position, + ]), + ).toEqual([ + [BOARD.GENERAL, 1, 0], + [BOARD.HELP, 2, 0], + [BOARD.BUGS, 2, 1], + [BOARD.OFF_TOPIC, 1, 1], + [BOARD.NEWS, 2, 0], + [BOARD.RELEASES, 3, 0], + ]); + expect(emitted).toEqual([ + event("content.container.moved", { + containerId: BOARD.NEWS, + fromParentId: BOARD.GENERAL, + parentId: BOARD.OFF_TOPIC, + position: 0, + }), + ]); + }); + + it("reorders among siblings", async () => { + const { placement, service } = harness(); + + await service.move(BOARD.BUGS, { parentId: BOARD.GENERAL, position: 0 }); + + expect([BOARD.BUGS, BOARD.NEWS, BOARD.HELP].map(placement)).toEqual([ + { parentId: BOARD.GENERAL, position: 0 }, + { parentId: BOARD.GENERAL, position: 1 }, + { parentId: BOARD.GENERAL, position: 2 }, + ]); + }); + + it("moves to the root", async () => { + const { service } = harness(); + + await expect( + service.move(BOARD.HELP, { parentId: null, position: 0 }), + ).resolves.toMatchObject({ depth: 1, parentId: null, position: 0 }); + }); + + it("lets a leaf go as deep as the collection allows", async () => { + const { service } = harness(); + + await expect( + service.move(BOARD.HELP, { parentId: BOARD.NEWS, position: 1 }), + ).resolves.toMatchObject({ depth: 3, parentId: BOARD.NEWS }); + }); + + it("announces nothing when a container is dropped where it was", async () => { + const { emitted, rows, service } = harness(); + const before = rows(); + + await expect( + service.move(BOARD.HELP, { parentId: BOARD.GENERAL, position: 1 }), + ).resolves.toMatchObject({ id: BOARD.HELP, position: 1 }); + expect(rows()).toEqual(before); + expect(emitted).toEqual([]); + }); +}); + +describe("delete", () => { + it("removes a leaf, renumbers its siblings and announces it", async () => { + const { emitted, placement, rows, service } = harness(); + + await service.delete(BOARD.HELP); + + expect(rows().some(candidate => candidate.id === BOARD.HELP)).toBe(false); + expect([BOARD.NEWS, BOARD.BUGS].map(placement)).toEqual([ + { parentId: BOARD.GENERAL, position: 0 }, + { parentId: BOARD.GENERAL, position: 1 }, + ]); + expect(emitted).toEqual([ + event("content.container.deleted", { + containerId: BOARD.HELP, + parentId: BOARD.GENERAL, + }), + ]); + }); +}); + +describe("refused writes leave the tree exactly as it was", () => { + type Write = ( + service: ContentContainerService, + ) => Promise; + + it.each<[string, Write, string]>([ + [ + "create with a taken slug", + async s => await s.create({ name: "Lobby", slug: "general" }), + "slugTaken", + ], + [ + "create with a slug that normalises to nothing", + async s => await s.create({ name: "Lobby", slug: "!!!" }), + "slugEmpty", + ], + [ + "create with values the definition refuses", + async s => + await s.create({ metadata: { color: "#1234567" }, name: "Lobby" }), + "invalidMetadata", + ], + [ + "create with a value the definition does not declare", + async s => + await s.create({ metadata: { nope: 1 } as never, name: "Lobby" }), + "invalidMetadata", + ], + [ + "create with a blank name", + async s => await s.create({ name: " " }), + "invalidInput", + ], + [ + "create under another collection's container", + async s => await s.create({ name: "Lobby", parentId: BOARD.ELSEWHERE }), + "parentNotFound", + ], + [ + "create under a missing parent", + async s => await s.create({ name: "Lobby", parentId: BOARD.MISSING }), + "parentNotFound", + ], + [ + "create below the deepest level", + async s => await s.create({ name: "Lobby", parentId: BOARD.RELEASES }), + "maxDepth", + ], + [ + "create past the end of the siblings", + async s => await s.create({ name: "Lobby", position: 3 }), + "invalidPosition", + ], + [ + "update to a taken slug", + async s => await s.update(BOARD.HELP, { slug: "general" }), + "slugTaken", + ], + [ + "update with values the definition refuses", + async s => + await s.update(BOARD.HELP, { metadata: { color: "#1234567" } }), + "invalidMetadata", + ], + [ + "update with nothing in it", + async s => await s.update(BOARD.HELP, {}), + "invalidInput", + ], + [ + "update another collection's container", + async s => await s.update(BOARD.ELSEWHERE, { name: "Mine" }), + "notFound", + ], + [ + "move into itself", + async s => + await s.move(BOARD.GENERAL, { parentId: BOARD.GENERAL, position: 0 }), + "cycle", + ], + [ + "move into a descendant", + async s => + await s.move(BOARD.GENERAL, { parentId: BOARD.RELEASES, position: 0 }), + "cycle", + ], + [ + "move a subtree past the deepest level", + async s => + await s.move(BOARD.NEWS, { parentId: BOARD.HELP, position: 0 }), + "maxDepth", + ], + [ + "move under another collection's container", + async s => + await s.move(BOARD.OFF_TOPIC, { + parentId: BOARD.ELSEWHERE, + position: 0, + }), + "parentNotFound", + ], + [ + "move another collection's container", + async s => await s.move(BOARD.ELSEWHERE, { parentId: null, position: 0 }), + "notFound", + ], + [ + "move past the end of the siblings", + async s => + await s.move(BOARD.HELP, { parentId: BOARD.OFF_TOPIC, position: 1 }), + "invalidPosition", + ], + [ + "delete a container with children", + async s => await s.delete(BOARD.GENERAL), + "hasChildren", + ], + [ + "delete a container that holds content", + async s => await s.delete(BOARD.OFF_TOPIC), + "hasContent", + ], + [ + "delete another collection's container", + async s => await s.delete(BOARD.ELSEWHERE), + "notFound", + ], + ])("refuses to %s", async (_label, write, code) => { + const { emitted, rows, service } = harness(); + const before = rows(); + + const error = await write(service).then( + () => null, + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(ContentContainerError); + expect(error).toMatchObject({ code }); + expect(rows()).toEqual(before); + expect(emitted).toEqual([]); + }); +}); + +describe("reads", () => { + it("lists one collection in tree order, with depths", async () => { + const { service } = harness(); + + expect((await service.list()).map(({ depth, id }) => [id, depth])).toEqual([ + [BOARD.GENERAL, 1], + [BOARD.NEWS, 2], + [BOARD.RELEASES, 3], + [BOARD.HELP, 2], + [BOARD.BUGS, 2], + [BOARD.OFF_TOPIC, 1], + ]); + }); + + it("never finds another collection's container", async () => { + const { service } = harness(); + + await expect(service.findById(BOARD.ELSEWHERE)).resolves.toBeNull(); + await expect(service.findBySlug("elsewhere")).resolves.toBeNull(); + await expect(service.descendantIds(BOARD.ELSEWHERE)).resolves.toBeNull(); + await expect(service.ancestors(BOARD.ELSEWHERE)).resolves.toEqual([]); + }); + + it("finds a container by id and by slug, with its depth", async () => { + const { service } = harness(); + + await expect(service.findById(BOARD.RELEASES)).resolves.toMatchObject({ + depth: 3, + slug: "releases", + }); + await expect(service.findBySlug("releases")).resolves.toMatchObject({ + id: BOARD.RELEASES, + }); + }); + + it("lists the direct children of a container, or the roots", async () => { + const { service } = harness(); + + expect((await service.children(BOARD.GENERAL)).map(({ id }) => id)).toEqual( + [BOARD.NEWS, BOARD.HELP, BOARD.BUGS], + ); + expect((await service.children(null)).map(({ id }) => id)).toEqual([ + BOARD.GENERAL, + BOARD.OFF_TOPIC, + ]); + }); + + it.each([ + [ + BOARD.GENERAL, + [BOARD.GENERAL, BOARD.NEWS, BOARD.HELP, BOARD.RELEASES, BOARD.BUGS], + ], + [BOARD.NEWS, [BOARD.NEWS, BOARD.RELEASES]], + [BOARD.RELEASES, [BOARD.RELEASES]], + ])("gives container %i and everything below it", async (id, expected) => { + const { service } = harness(); + + expect(new Set(await service.descendantIds(id))).toEqual(new Set(expected)); + }); + + it("is null for the subtree of a container that does not exist", async () => { + const { service } = harness(); + + await expect(service.descendantIds(BOARD.MISSING)).resolves.toBeNull(); + }); + + it.each([ + [BOARD.RELEASES, [BOARD.GENERAL, BOARD.NEWS]], + [BOARD.HELP, [BOARD.GENERAL]], + [BOARD.GENERAL, []], + [BOARD.MISSING, []], + ])("gives the ancestors of %i, root first", async (id, expected) => { + const { service } = harness(); + + expect((await service.ancestors(id)).map(record => record.id)).toEqual( + expected, + ); + }); + + it("strips every metadata field the definition does not publish", async () => { + const { service } = harness(); + const record = await service.findById(BOARD.GENERAL); + if (!record) throw new Error("Expected the seeded board."); + + expect(service.toPublic(record)).toEqual({ + depth: 1, + id: BOARD.GENERAL, + metadata: { color: "#ffffff", description: "Say hi" }, + name: "General", + parentId: null, + position: 0, + slug: "general", + }); + }); +}); + +describe("contentContainerUsageFrom", () => { + const threads = createContentModel(testThreadContentType); + + const harnessWithThread = async (board: number) => { + const context = harness({ + core: { contentModels: [{ model: threads, pluginId: PLUGIN_ID }] }, + usage: null, + }); + await context.memory.db.insert(threads.table).values({ + author: 1, + board, + slug: "hello", + title: "Hello", + }); + + return context; + }; + + it("refuses to delete a container a registered content type still points at", async () => { + const { rows, service } = await harnessWithThread(BOARD.HELP); + const before = rows(); + + await expect(service.delete(BOARD.HELP)).rejects.toMatchObject({ + code: "hasContent", + }); + expect(rows()).toEqual(before); + }); + + it("deletes a container nothing points at", async () => { + const { rows, service } = await harnessWithThread(BOARD.BUGS); + + await service.delete(BOARD.HELP); + + expect(rows().some(candidate => candidate.id === BOARD.HELP)).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/containers/server/service.ts b/packages/vitnode/src/content/containers/server/service.ts new file mode 100644 index 0000000000..4a4f96f54d --- /dev/null +++ b/packages/vitnode/src/content/containers/server/service.ts @@ -0,0 +1,594 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; +import type { z } from "zod"; + +import { and, eq, inArray, sql } from "drizzle-orm"; + +import type { ContentDatabase } from "../../server/service"; +import type { + AnyContentContainerDefinition, + ContentContainerCreateInput, + ContentContainerErrorCode, + ContentContainerMoveInput, + ContentContainerPublicRecord, + ContentContainerRecord, + ContentContainerUpdateInput, +} from "../types"; + +import { core_content_containers } from "../../../database/content-containers"; +import { invalidatePageCacheTags } from "../../../page-cache/store.server"; +import { contentContainerTag } from "../../cache"; +import { contentDefinitionOf } from "../../server/model"; +import { slugify } from "../../slug"; +import { contentContainerFieldOf } from "../association"; +import { + CONTENT_CONTAINER_MAX_PER_DEFINITION, + CONTENT_CONTAINER_SLUG_MAX_LENGTH, +} from "../const"; +import { ContentContainerError } from "../errors"; +import { + contentContainerAncestors, + contentContainerSubtreeIds, + orderContentContainers, + planContentContainerCreate, + planContentContainerDelete, + planContentContainerMove, +} from "../tree"; + +type ContainerRow = typeof core_content_containers.$inferSelect; + +export type ContentContainerUsageCheck = ( + tx: ContentDatabase, + containerIds: readonly number[], +) => Promise; + +export interface ContentContainerReferencingModel { + column: PgColumn; + table: PgTable; +} + +export const contentContainerReferencingModels = ( + c: Context, + definitionId: string, +): ContentContainerReferencingModel[] => { + const models = c.get("core")?.contentModels ?? []; + + return models.flatMap(({ model }) => { + const container = contentContainerFieldOf(contentDefinitionOf(model)); + if (container?.field.definition.id !== definitionId) return []; + + const column = (model.columns as Record)[container.name]; + + return column ? [{ column, table: model.table }] : []; + }); +}; + +export const contentContainerUsageFrom = + ( + models: readonly ContentContainerReferencingModel[], + ): ContentContainerUsageCheck => + async (tx, containerIds) => { + if (containerIds.length === 0) return false; + + for (const { column, table } of models) { + const [row] = await tx + .select({ value: column }) + .from(table) + .where(inArray(column, [...containerIds])) + .limit(1); + if (row) return true; + } + + return false; + }; + +export interface ContentContainerService< + TDefinition extends AnyContentContainerDefinition, +> { + ancestors: (id: number) => Promise[]>; + children: ( + parentId: null | number, + ) => Promise[]>; + create: ( + input: ContentContainerCreateInput, + ) => Promise>; + delete: (id: number) => Promise; + descendantIds: (id: number) => Promise; + findById: (id: number) => Promise | null>; + findBySlug: ( + slug: string, + ) => Promise | null>; + list: () => Promise[]>; + move: ( + id: number, + input: ContentContainerMoveInput, + ) => Promise>; + toPublic: ( + record: ContentContainerRecord, + ) => ContentContainerPublicRecord; + update: ( + id: number, + input: ContentContainerUpdateInput, + ) => Promise>; +} + +const zodErrorMessage = (error: z.ZodError): string => + error.issues + .map(issue => + issue.path.length > 0 + ? `${issue.path.join(".")}: ${issue.message}` + : issue.message, + ) + .join("; "); + +export const contentContainerInputRefusal = ( + error: z.ZodError, +): { code: "invalidInput" | "invalidMetadata"; message: string } => ({ + code: error.issues.some(issue => issue.path[0] === "metadata") + ? "invalidMetadata" + : "invalidInput", + message: zodErrorMessage(error), +}); + +export const createContentContainerService = < + TDefinition extends AnyContentContainerDefinition, +>( + c: Context, + { + definition, + pluginId, + usage, + }: { + definition: TDefinition; + pluginId: string; + usage?: ContentContainerUsageCheck; + }, +): ContentContainerService => { + const definitionId = definition.id; + const fieldNames = Object.keys(definition.fields); + const rules = { + hierarchy: definition.hierarchy.enabled, + maxDepth: definition.hierarchy.maxDepth, + mode: definition.ordering.mode, + }; + const inUse = + usage ?? + contentContainerUsageFrom( + contentContainerReferencingModels(c, definitionId), + ); + + const refuse = (code: ContentContainerErrorCode, message?: string): never => { + throw new ContentContainerError({ code, definitionId, message }); + }; + + const readAll = async (db: ContentDatabase): Promise => + await db + .select() + .from(core_content_containers) + .where(eq(core_content_containers.definitionId, definitionId)); + + const lock = async (tx: ContentDatabase): Promise => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`core_content_containers:${definitionId}`}))`, + ); + }; + + const toRecord = ( + row: ContainerRow, + depth: number, + ): ContentContainerRecord => + ({ + createdAt: row.createdAt, + depth, + id: row.id, + metadata: Object.fromEntries( + fieldNames.map(name => [name, row.metadata[name] ?? null]), + ), + name: row.name, + parentId: row.parentId, + position: row.position, + slug: row.slug, + updatedAt: row.updatedAt, + }) as ContentContainerRecord; + + const ordered = (rows: readonly ContainerRow[]) => + orderContentContainers(rows, definition.ordering.mode).map(row => + toRecord(row, row.depth), + ); + + const recordOf = ( + rows: readonly ContainerRow[], + id: number, + ): ContentContainerRecord | null => + ordered(rows).find(record => record.id === id) ?? null; + + const parseMetadata = ( + schema: z.ZodType, + value: unknown, + ): Record => { + const parsed = schema.safeParse(value ?? {}); + if (!parsed.success) { + return refuse("invalidMetadata", zodErrorMessage(parsed.error)); + } + + return parsed.data as Record; + }; + + const normalizeSlug = (value: string): string => { + const slug = slugify(value, CONTENT_CONTAINER_SLUG_MAX_LENGTH); + if (slug === "") return refuse("slugEmpty"); + + return slug; + }; + + const resolveSlug = ( + rows: readonly ContainerRow[], + { + explicit, + from, + self, + }: { explicit?: string; from: string; self?: number }, + ): string => { + const taken = new Set( + rows.filter(row => row.id !== self).map(row => row.slug), + ); + + if (explicit !== undefined) { + const slug = normalizeSlug(explicit); + if (taken.has(slug)) refuse("slugTaken"); + + return slug; + } + + const base = + slugify(from, CONTENT_CONTAINER_SLUG_MAX_LENGTH) || "container"; + let candidate = base; + for (let suffix = 2; taken.has(candidate); suffix++) { + const tail = `-${suffix}`; + candidate = `${base.slice(0, CONTENT_CONTAINER_SLUG_MAX_LENGTH - tail.length)}${tail}`; + } + + return candidate; + }; + + const applyPlacements = async ( + tx: ContentDatabase, + placements: readonly { + id: number; + parentId: null | number; + position: number; + }[], + ): Promise => { + for (const placement of placements) { + await tx + .update(core_content_containers) + .set({ + parentId: placement.parentId, + position: placement.position, + updatedAt: new Date(), + }) + .where( + and( + eq(core_content_containers.id, placement.id), + eq(core_content_containers.definitionId, definitionId), + ), + ); + } + }; + + const expire = async (mode: "hard" | "soft"): Promise => { + await invalidatePageCacheTags([contentContainerTag(definitionId)], { + mode, + }); + }; + + const emit = async < + TName extends + | "content.container.created" + | "content.container.deleted" + | "content.container.moved" + | "content.container.updated", + >( + name: TName, + payload: Record, + ): Promise => { + await c + .get("events") + .emit(name, { definitionId, pluginId, ...payload } as never); + }; + + const parseInput = ( + schema: TSchema, + input: unknown, + ): z.infer => { + const parsed = schema.safeParse(input); + if (!parsed.success) { + const { code, message } = contentContainerInputRefusal(parsed.error); + + return refuse(code, message); + } + + return parsed.data; + }; + + return { + ancestors: async id => { + const rows = await readAll(c.get("db")); + const records = new Map(ordered(rows).map(record => [record.id, record])); + + return contentContainerAncestors(rows, id).flatMap(row => { + const record = records.get(row.id); + + return record ? [record] : []; + }); + }, + + children: async parentId => + ordered(await readAll(c.get("db"))).filter( + record => record.parentId === parentId, + ), + + create: async input => { + const values = parseInput(definition.schemas.create, input) as { + metadata?: Record; + name: string; + parentId?: null | number; + position?: number; + slug?: string; + }; + const metadata = parseMetadata( + definition.schemas.metadata, + values.metadata, + ); + const parentId = values.parentId ?? null; + + const created = await c.get("db").transaction(async tx => { + await lock(tx); + const rows = await readAll(tx); + if (rows.length >= CONTENT_CONTAINER_MAX_PER_DEFINITION) { + refuse("limitReached"); + } + + const plan = planContentContainerCreate( + rows, + { parentId, position: values.position }, + rules, + ); + if (!plan.ok) return refuse(plan.code); + + const slug = resolveSlug(rows, { + explicit: values.slug, + from: values.name, + }); + + await applyPlacements(tx, plan.shifted); + const [row] = await tx + .insert(core_content_containers) + .values({ + definitionId, + metadata, + name: values.name, + parentId, + pluginId, + position: plan.position, + slug, + }) + .returning(); + + return recordOf([...rows, row], row.id) ?? refuse("notFound"); + }); + + await expire("soft"); + await emit("content.container.created", { + containerId: created.id, + parentId: created.parentId, + }); + + return created; + }, + + delete: async id => { + const removed = await c.get("db").transaction(async tx => { + await lock(tx); + const rows = await readAll(tx); + const plan = planContentContainerDelete(rows, id); + if (!plan.ok) return refuse(plan.code); + if (await inUse(tx, [id])) refuse("hasContent"); + + const row = + rows.find(candidate => candidate.id === id) ?? refuse("notFound"); + await tx + .delete(core_content_containers) + .where( + and( + eq(core_content_containers.id, id), + eq(core_content_containers.definitionId, definitionId), + ), + ); + await applyPlacements(tx, plan.shifted); + + return row; + }); + + await expire("hard"); + await emit("content.container.deleted", { + containerId: removed.id, + parentId: removed.parentId, + }); + }, + + descendantIds: async id => { + const rows = await readAll(c.get("db")); + if (!rows.some(row => row.id === id)) return null; + + return [...contentContainerSubtreeIds(rows, id)]; + }, + + findById: async id => { + if (!Number.isSafeInteger(id) || id <= 0) return null; + const rows = await readAll(c.get("db")); + + return recordOf(rows, id); + }, + + findBySlug: async slug => { + const rows = await readAll(c.get("db")); + const row = rows.find(candidate => candidate.slug === slug); + + return row ? recordOf(rows, row.id) : null; + }, + + list: async () => ordered(await readAll(c.get("db"))), + + move: async (id, input) => { + const { parentId, position } = parseInput( + definition.schemas.move, + input, + ) as unknown as ContentContainerMoveInput; + + const { changed, from, moved } = await c + .get("db") + .transaction(async tx => { + await lock(tx); + const rows = await readAll(tx); + const plan = planContentContainerMove( + rows, + { id, parentId, position }, + rules, + ); + if (!plan.ok) return refuse(plan.code); + + const before = rows.find(row => row.id === id) ?? refuse("notFound"); + await applyPlacements(tx, plan.placements); + const placed = new Map(plan.placements.map(item => [item.id, item])); + const after = rows.map(row => { + const placement = placed.get(row.id); + + return placement ? { ...row, ...placement } : row; + }); + + return { + changed: plan.placements.length > 0, + from: before.parentId, + moved: recordOf(after, id) ?? refuse("notFound"), + }; + }); + + if (!changed) return moved; + + await expire("soft"); + await emit("content.container.moved", { + containerId: moved.id, + fromParentId: from, + parentId: moved.parentId, + position: moved.position, + }); + + return moved; + }, + + toPublic: record => + ({ + depth: record.depth, + id: record.id, + metadata: Object.fromEntries( + definition.publicApi.fields.map(name => [ + name, + (record.metadata as Record)[name] ?? null, + ]), + ), + name: record.name, + parentId: record.parentId, + position: record.position, + slug: record.slug, + }) as ContentContainerPublicRecord, + + update: async (id, input) => { + const values = parseInput(definition.schemas.update, input) as { + metadata?: Record; + name?: string; + slug?: string; + }; + const patch = + values.metadata === undefined + ? undefined + : parseMetadata(definition.schemas.metadataPatch, values.metadata); + + const { changedFields, record, slugChanged } = await c + .get("db") + .transaction(async tx => { + await lock(tx); + const rows = await readAll(tx); + const row = rows.find(candidate => candidate.id === id); + if (!row) return refuse("notFound"); + + const slug = + values.slug === undefined + ? row.slug + : resolveSlug(rows, { + explicit: values.slug, + from: row.name, + self: id, + }); + const metadata = patch + ? parseMetadata(definition.schemas.metadata, { + ...Object.fromEntries( + Object.entries(row.metadata).filter(([key]) => + Object.hasOwn(definition.fields, key), + ), + ), + ...patch, + }) + : row.metadata; + const name = values.name ?? row.name; + + const changed = [ + ...(name === row.name ? [] : ["name"]), + ...(slug === row.slug ? [] : ["slug"]), + ...Object.keys(patch ?? {}).filter( + key => + JSON.stringify(row.metadata[key] ?? null) !== + JSON.stringify(metadata[key] ?? null), + ), + ]; + + const [updated] = + changed.length === 0 + ? [row] + : await tx + .update(core_content_containers) + .set({ metadata, name, slug, updatedAt: new Date() }) + .where( + and( + eq(core_content_containers.id, id), + eq(core_content_containers.definitionId, definitionId), + ), + ) + .returning(); + + return { + changedFields: changed, + record: + recordOf( + rows.map(candidate => + candidate.id === id ? updated : candidate, + ), + id, + ) ?? refuse("notFound"), + slugChanged: slug !== row.slug, + }; + }); + + if (changedFields.length > 0) { + await expire(slugChanged ? "hard" : "soft"); + await emit("content.container.updated", { + changedFields, + containerId: record.id, + parentId: record.parentId, + }); + } + + return record; + }, + }; +}; diff --git a/packages/vitnode/src/content/containers/tree.test.ts b/packages/vitnode/src/content/containers/tree.test.ts new file mode 100644 index 0000000000..141f9043f7 --- /dev/null +++ b/packages/vitnode/src/content/containers/tree.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from "vitest"; + +import type { + ContentContainerPlacement, + ContentContainerTreeRow, + ContentContainerTreeRules, +} from "./tree"; + +import { + contentContainerAncestors, + contentContainerSubtreeIds, + contentContainerTree, + orderContentContainers, + planContentContainerCreate, + planContentContainerDelete, + planContentContainerMove, +} from "./tree"; + +const row = ( + id: number, + name: string, + parentId: null | number, + position: number, +): ContentContainerTreeRow => ({ id, name, parentId, position }); + +const rows: ContentContainerTreeRow[] = [ + row(6, "Help", null, 2), + row(4, "announcements", 1, 1), + row(2, "Archive", null, 1), + row(5, "Old rules", 3, 0), + row(1, "News", null, 0), + row(3, "Rules", 1, 0), +]; + +const rules: ContentContainerTreeRules = { + hierarchy: true, + maxDepth: 3, + mode: "manual", +}; + +const apply = ( + source: readonly ContentContainerTreeRow[], + placements: readonly ContentContainerPlacement[], +): ContentContainerTreeRow[] => + source.map(current => { + const placement = placements.find(({ id }) => id === current.id); + + return placement ? { ...current, ...placement } : current; + }); + +const expectContiguousPositions = ( + result: readonly ContentContainerTreeRow[], +): void => { + const byParent = new Map(); + for (const { parentId, position } of result) { + byParent.set(parentId, [...(byParent.get(parentId) ?? []), position]); + } + + for (const positions of byParent.values()) { + expect(positions.toSorted((a, b) => a - b)).toEqual( + positions.map((_, index) => index), + ); + } +}; + +const ids = (list: readonly { id: number }[]) => list.map(({ id }) => id); + +describe("orderContentContainers", () => { + it("lists rows in pre-order with their depth, siblings by position", () => { + expect( + orderContentContainers(rows).map(({ depth, id }) => [id, depth]), + ).toEqual([ + [1, 1], + [3, 2], + [5, 3], + [4, 2], + [2, 1], + [6, 1], + ]); + }); + + it("sorts siblings by name, ignoring case, in alphabetical mode", () => { + expect(ids(orderContentContainers(rows, "alphabetical"))).toEqual([ + 2, 6, 1, 4, 3, 5, + ]); + }); + + it("breaks a position tie by id", () => { + expect( + ids(orderContentContainers([row(9, "B", null, 0), row(7, "A", null, 0)])), + ).toEqual([7, 9]); + }); + + it("surfaces a row whose parent is missing at the root, with its subtree", () => { + const ordered = orderContentContainers([ + row(1, "Root", null, 0), + row(2, "Orphan", 99, 0), + row(3, "Orphan child", 2, 0), + ]); + + expect(ordered.map(({ depth, id }) => [id, depth])).toEqual([ + [1, 1], + [2, 1], + [3, 2], + ]); + }); + + it("terminates on a cycle and still lists the reachable rows", () => { + const ordered = orderContentContainers([ + row(1, "Root", null, 0), + row(2, "A", 3, 0), + row(3, "B", 2, 0), + row(4, "Self", 4, 0), + ]); + + expect(ids(ordered)).toContain(1); + expect(new Set(ids(ordered)).size).toBe(ordered.length); + }); +}); + +describe("contentContainerTree", () => { + it("nests rows under their parents", () => { + const tree = contentContainerTree(orderContentContainers(rows)); + + expect(ids(tree)).toEqual([1, 2, 6]); + expect(ids(tree[0].children)).toEqual([3, 4]); + expect(ids(tree[0].children[0].children)).toEqual([5]); + expect(tree[1].children).toEqual([]); + }); + + it("keeps a row whose parent is not in the list at the root", () => { + expect( + ids( + contentContainerTree([ + { id: 1, parentId: null }, + { id: 2, parentId: 99 }, + ]), + ), + ).toEqual([1, 2]); + }); +}); + +describe("contentContainerAncestors", () => { + it.each([ + [5, [1, 3]], + [3, [1]], + [1, []], + [99, []], + ])("lists the ancestors of %i root first", (id, expected) => { + expect(ids(contentContainerAncestors(rows, id))).toEqual(expected); + }); + + it("terminates on a cycle", () => { + expect( + ids(contentContainerAncestors([row(1, "A", 2, 0), row(2, "B", 1, 0)], 1)), + ).toEqual([2]); + }); +}); + +describe("contentContainerSubtreeIds", () => { + it.each([ + [1, [1, 3, 4, 5]], + [3, [3, 5]], + [6, [6]], + ])("collects %i and everything below it", (id, expected) => { + expect( + [...contentContainerSubtreeIds(rows, id)].toSorted((a, b) => a - b), + ).toEqual(expected); + }); +}); + +describe("planContentContainerCreate", () => { + const create = ( + parentId: null | number, + position?: number, + overrides: Partial = {}, + source: readonly ContentContainerTreeRow[] = rows, + ) => + planContentContainerCreate( + source, + { parentId, position }, + { ...rules, ...overrides }, + ); + + const expectValid = ( + plan: ReturnType, + parentId: null | number, + source: readonly ContentContainerTreeRow[] = rows, + ) => { + if (!plan.ok) throw new Error(`Expected a plan, got ${plan.code}`); + expectContiguousPositions([ + ...apply(source, plan.shifted), + row(100, "New", parentId, plan.position), + ]); + }; + + it.each([ + ["the root", null, 3], + ["a parent", 1, 2], + ["a parent without children", 2, 0], + ])("appends to %s when no position is given", (_label, parentId, at) => { + const plan = create(parentId); + + expect(plan).toEqual({ ok: true, position: at, shifted: [] }); + expectValid(plan, parentId); + }); + + it("shifts the siblings at and after an explicit position", () => { + const plan = create(1, 0); + + expect(plan).toEqual({ + ok: true, + position: 0, + shifted: [ + { id: 3, parentId: 1, position: 1 }, + { id: 4, parentId: 1, position: 2 }, + ], + }); + expectValid(plan, 1); + }); + + it("closes gaps in the sibling positions", () => { + const gappy = [row(1, "A", null, 0), row(2, "B", null, 7)]; + const plan = create(null, 1, {}, gappy); + + expect(plan).toEqual({ + ok: true, + position: 1, + shifted: [{ id: 2, parentId: null, position: 2 }], + }); + expectValid(plan, null, gappy); + }); + + it("ignores the position in alphabetical mode", () => { + const plan = create(1, 0, { mode: "alphabetical" }); + + expect(plan).toEqual({ ok: true, position: 2, shifted: [] }); + }); + + it.each([ + ["a position past the end", 1, 3, {}, "invalidPosition"], + ["a negative position", 1, -1, {}, "invalidPosition"], + ["a fractional position", 1, 0.5, {}, "invalidPosition"], + ["a missing parent", 99, undefined, {}, "parentNotFound"], + ["a parent at the deepest level", 5, undefined, {}, "maxDepth"], + [ + "any parent when the hierarchy is disabled", + 1, + undefined, + { hierarchy: false, maxDepth: 1 }, + "maxDepth", + ], + ] as const)("refuses %s", (_label, parentId, position, overrides, code) => { + expect(create(parentId, position, overrides)).toEqual({ code, ok: false }); + }); + + it("still allows a root when the hierarchy is disabled", () => { + expect(create(null, 0, { hierarchy: false, maxDepth: 1 }).ok).toBe(true); + }); +}); + +describe("planContentContainerMove", () => { + const move = ( + id: number, + parentId: null | number, + position: number, + overrides: Partial = {}, + ) => + planContentContainerMove( + rows, + { id, parentId, position }, + { ...rules, ...overrides }, + ); + + const expectMoved = ( + plan: ReturnType, + id: number, + parentId: null | number, + ) => { + if (!plan.ok) throw new Error(`Expected a plan, got ${plan.code}`); + const result = apply(rows, plan.placements); + + expect(result.find(current => current.id === id)?.parentId).toBe(parentId); + expectContiguousPositions(result); + }; + + it("reorders siblings", () => { + const plan = move(4, 1, 0); + + expect(plan).toEqual({ + ok: true, + placements: [ + { id: 4, parentId: 1, position: 0 }, + { id: 3, parentId: 1, position: 1 }, + ], + }); + expectMoved(plan, 4, 1); + }); + + it("renumbers both sibling lists when moving to another parent", () => { + const plan = move(3, null, 0); + + expect(plan).toEqual({ + ok: true, + placements: [ + { id: 3, parentId: null, position: 0 }, + { id: 1, parentId: null, position: 1 }, + { id: 2, parentId: null, position: 2 }, + { id: 6, parentId: null, position: 3 }, + { id: 4, parentId: 1, position: 0 }, + ], + }); + expectMoved(plan, 3, null); + }); + + it.each([ + ["a leaf under a sibling", 4, 3, 1], + ["a subtree that just fits", 3, 2, 0], + ["a node to the end of the root", 5, null, 3], + ])("allows %s", (_label, id, parentId, position) => { + expectMoved(move(id, parentId, position), id, parentId); + }); + + it("returns no placements for a move to where it already is", () => { + expect(move(3, 1, 0)).toEqual({ ok: true, placements: [] }); + }); + + it("appends regardless of position in alphabetical mode", () => { + const plan = move(3, null, 0, { mode: "alphabetical" }); + + expect(plan).toEqual({ + ok: true, + placements: [ + { id: 3, parentId: null, position: 3 }, + { id: 4, parentId: 1, position: 0 }, + ], + }); + expectMoved(plan, 3, null); + }); + + it.each([ + ["a missing container", 99, null, 0, {}, "notFound"], + ["a container under itself", 1, 1, 0, {}, "cycle"], + ["a container under its child", 1, 3, 0, {}, "cycle"], + ["a container under its grandchild", 1, 5, 0, {}, "cycle"], + ["a missing parent", 4, 99, 0, {}, "parentNotFound"], + ["a subtree that would end too deep", 3, 4, 0, {}, "maxDepth"], + ["a leaf under the deepest level", 4, 5, 0, {}, "maxDepth"], + [ + "any parent when the hierarchy is disabled", + 6, + 2, + 0, + { hierarchy: false, maxDepth: 1 }, + "maxDepth", + ], + ["a position past the end", 4, 2, 1, {}, "invalidPosition"], + ["a negative position", 4, 1, -1, {}, "invalidPosition"], + ["a position past the end among siblings", 4, 1, 2, {}, "invalidPosition"], + ] as const)( + "refuses %s", + (_label, id, parentId, position, overrides, code) => { + expect(move(id, parentId, position, overrides)).toEqual({ + code, + ok: false, + }); + }, + ); +}); + +describe("planContentContainerDelete", () => { + it("closes the gap among the siblings", () => { + const plan = planContentContainerDelete(rows, 2); + + expect(plan).toEqual({ + ok: true, + shifted: [{ id: 6, parentId: null, position: 1 }], + }); + if (plan.ok) { + expectContiguousPositions( + apply( + rows.filter(current => current.id !== 2), + plan.shifted, + ), + ); + } + }); + + it("shifts nothing for the last sibling", () => { + expect(planContentContainerDelete(rows, 4)).toEqual({ + ok: true, + shifted: [], + }); + }); + + it.each([ + ["a container with children", 3, "hasChildren"], + ["a missing container", 99, "notFound"], + ] as const)("refuses %s", (_label, id, code) => { + expect(planContentContainerDelete(rows, id)).toEqual({ code, ok: false }); + }); +}); diff --git a/packages/vitnode/src/content/containers/tree.ts b/packages/vitnode/src/content/containers/tree.ts new file mode 100644 index 0000000000..d03c6ef4a6 --- /dev/null +++ b/packages/vitnode/src/content/containers/tree.ts @@ -0,0 +1,315 @@ +import type { + ContentContainerErrorCode, + ContentContainerOrderingMode, +} from "./types"; + +export interface ContentContainerTreeRow { + id: number; + name: string; + parentId: null | number; + position: number; +} + +export type ContentContainerTreeNode = TRow & { + children: ContentContainerTreeNode[]; +}; + +export interface ContentContainerPlacement { + id: number; + parentId: null | number; + position: number; +} + +export type ContentContainerPlan = + | (TValue & { ok: true }) + | { code: ContentContainerErrorCode; ok: false }; + +const compareSiblings = + (mode: ContentContainerOrderingMode) => + (a: ContentContainerTreeRow, b: ContentContainerTreeRow): number => { + if (mode === "alphabetical") { + const byName = a.name.localeCompare(b.name, undefined, { + sensitivity: "base", + }); + if (byName !== 0) return byName; + } else if (a.position !== b.position) { + return a.position - b.position; + } + + return a.id - b.id; + }; + +const childrenByParent = ( + rows: readonly TRow[], + mode: ContentContainerOrderingMode, +): Map => { + const known = new Set(rows.map(row => row.id)); + const byParent = new Map(); + + for (const row of rows) { + const parent = + row.parentId !== null && known.has(row.parentId) ? row.parentId : null; + const siblings = byParent.get(parent) ?? []; + siblings.push(row); + byParent.set(parent, siblings); + } + + const compare = compareSiblings(mode); + for (const siblings of byParent.values()) siblings.sort(compare); + + return byParent; +}; + +export const orderContentContainers = ( + rows: readonly TRow[], + mode: ContentContainerOrderingMode = "manual", +): (TRow & { depth: number })[] => { + const byParent = childrenByParent(rows, mode); + const ordered: (TRow & { depth: number })[] = []; + const visited = new Set(); + + const walk = (parent: null | number, depth: number): void => { + for (const row of byParent.get(parent) ?? []) { + if (visited.has(row.id)) continue; + visited.add(row.id); + ordered.push({ ...row, depth }); + walk(row.id, depth + 1); + } + }; + walk(null, 1); + + return ordered; +}; + +export const contentContainerTree = < + TRow extends { id: number; parentId: null | number }, +>( + rows: readonly TRow[], +): ContentContainerTreeNode[] => { + const nodes = new Map>(); + const roots: ContentContainerTreeNode[] = []; + + for (const row of rows) nodes.set(row.id, { ...row, children: [] }); + + for (const node of nodes.values()) { + const parent = + node.parentId === null ? undefined : nodes.get(node.parentId); + if (parent) parent.children.push(node); + else roots.push(node); + } + + return roots; +}; + +export const contentContainerAncestors = ( + rows: readonly TRow[], + id: number, +): TRow[] => { + const byId = new Map(rows.map(row => [row.id, row])); + const ancestors: TRow[] = []; + const seen = new Set([id]); + let parentId = byId.get(id)?.parentId ?? null; + + while (parentId !== null && !seen.has(parentId)) { + const parent = byId.get(parentId); + if (!parent) break; + seen.add(parentId); + ancestors.unshift(parent); + parentId = parent.parentId; + } + + return ancestors; +}; + +const childIdsByParent = ( + rows: readonly ContentContainerTreeRow[], +): Map => { + const children = new Map(); + for (const row of rows) { + if (row.parentId === null) continue; + children.set(row.parentId, [...(children.get(row.parentId) ?? []), row.id]); + } + + return children; +}; + +export const contentContainerSubtreeIds = ( + rows: readonly ContentContainerTreeRow[], + id: number, +): Set => { + const children = childIdsByParent(rows); + const subtree = new Set(); + const stack = [id]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined || subtree.has(current)) continue; + subtree.add(current); + stack.push(...(children.get(current) ?? [])); + } + + return subtree; +}; + +export const contentContainerDepth = ( + rows: readonly ContentContainerTreeRow[], + id: number, +): number => contentContainerAncestors(rows, id).length + 1; + +const subtreeHeight = ( + rows: readonly ContentContainerTreeRow[], + id: number, +): number => { + const children = childIdsByParent(rows); + const height = (current: number, seen: Set): number => { + if (seen.has(current)) return 0; + const next = new Set(seen).add(current); + + return ( + 1 + + Math.max( + 0, + ...(children.get(current) ?? []).map(child => height(child, next)), + ) + ); + }; + + return height(id, new Set()); +}; + +const renumber = ( + siblings: readonly ContentContainerTreeRow[], + parentId: null | number, +): ContentContainerPlacement[] => + siblings.flatMap((row, position) => + row.position === position && row.parentId === parentId + ? [] + : [{ id: row.id, parentId, position }], + ); + +const siblingsByPosition = ( + rows: readonly ContentContainerTreeRow[], + parentId: null | number, +): ContentContainerTreeRow[] => + rows.filter(row => row.parentId === parentId).sort(compareSiblings("manual")); + +const checkParent = ( + rows: readonly ContentContainerTreeRow[], + parentId: null | number, + { hierarchy, maxDepth }: { hierarchy: boolean; maxDepth: number }, + subtree = 1, +): ContentContainerErrorCode | null => { + if (parentId === null) return subtree > maxDepth ? "maxDepth" : null; + if (!hierarchy) return "maxDepth"; + if (!rows.some(row => row.id === parentId)) return "parentNotFound"; + + return contentContainerDepth(rows, parentId) + subtree > maxDepth + ? "maxDepth" + : null; +}; + +export interface ContentContainerTreeRules { + hierarchy: boolean; + maxDepth: number; + mode: ContentContainerOrderingMode; +} + +export const planContentContainerCreate = ( + rows: readonly ContentContainerTreeRow[], + { + parentId, + position, + }: { parentId: null | number; position: number | undefined }, + rules: ContentContainerTreeRules, +): ContentContainerPlan<{ + position: number; + shifted: ContentContainerPlacement[]; +}> => { + const problem = checkParent(rows, parentId, rules); + if (problem) return { code: problem, ok: false }; + + const siblings = siblingsByPosition(rows, parentId); + const at = + rules.mode === "alphabetical" || position === undefined + ? siblings.length + : position; + if (!Number.isInteger(at) || at < 0 || at > siblings.length) { + return { code: "invalidPosition", ok: false }; + } + + const shifted = siblings.flatMap((row, index) => { + const next = index < at ? index : index + 1; + + return row.position === next + ? [] + : [{ id: row.id, parentId, position: next }]; + }); + + return { ok: true, position: at, shifted }; +}; + +export const planContentContainerMove = ( + rows: readonly ContentContainerTreeRow[], + { + id, + parentId, + position, + }: { id: number; parentId: null | number; position: number }, + rules: ContentContainerTreeRules, +): ContentContainerPlan<{ placements: ContentContainerPlacement[] }> => { + const node = rows.find(row => row.id === id); + if (!node) return { code: "notFound", ok: false }; + + if (parentId !== null) { + if (parentId === id) return { code: "cycle", ok: false }; + if (!rows.some(row => row.id === parentId)) { + return { code: "parentNotFound", ok: false }; + } + if (contentContainerSubtreeIds(rows, id).has(parentId)) { + return { code: "cycle", ok: false }; + } + } + + const problem = checkParent(rows, parentId, rules, subtreeHeight(rows, id)); + if (problem) return { code: problem, ok: false }; + + const remaining = rows.filter(row => row.id !== id); + const target = siblingsByPosition(remaining, parentId); + const at = rules.mode === "alphabetical" ? target.length : position; + if (!Number.isInteger(at) || at < 0 || at > target.length) { + return { code: "invalidPosition", ok: false }; + } + + const placed = [...target]; + placed.splice(at, 0, node); + + const placements = renumber(placed, parentId); + if (node.parentId !== parentId) { + placements.push( + ...renumber(siblingsByPosition(remaining, node.parentId), node.parentId), + ); + } + + return { ok: true, placements }; +}; + +export const planContentContainerDelete = ( + rows: readonly ContentContainerTreeRow[], + id: number, +): ContentContainerPlan<{ shifted: ContentContainerPlacement[] }> => { + const node = rows.find(row => row.id === id); + if (!node) return { code: "notFound", ok: false }; + if (rows.some(row => row.parentId === id)) { + return { code: "hasChildren", ok: false }; + } + + return { + ok: true, + shifted: renumber( + siblingsByPosition( + rows.filter(row => row.id !== id), + node.parentId, + ), + node.parentId, + ), + }; +}; diff --git a/packages/vitnode/src/content/containers/types.ts b/packages/vitnode/src/content/containers/types.ts new file mode 100644 index 0000000000..c55d97f4dd --- /dev/null +++ b/packages/vitnode/src/content/containers/types.ts @@ -0,0 +1,183 @@ +import type { z } from "zod"; + +import type { + ContentBooleanField, + ContentEnumField, + ContentFieldValue, + ContentNumberField, + ContentTextareaField, + ContentTextField, + ContentValuesOf, + Prettify, +} from "../types"; +import type { + CONTENT_CONTAINER_ERROR_CODES, + CONTENT_CONTAINER_ORDERING_MODES, +} from "./const"; + +export type ContentContainerFieldDescriptor = + | ContentBooleanField + | ContentEnumField + | ContentNumberField + | ContentTextareaField + | ContentTextField; + +export type ContentContainerFieldMap = Record< + string, + ContentContainerFieldDescriptor +>; + +export type ContentContainerOrderingMode = + (typeof CONTENT_CONTAINER_ORDERING_MODES)[number]; + +export type ContentContainerErrorCode = + (typeof CONTENT_CONTAINER_ERROR_CODES)[number]; + +export interface ContentContainerHierarchyInput { + enabled?: boolean; + maxDepth?: number; +} + +export interface ContentContainerOrderingInput { + mode?: ContentContainerOrderingMode; +} + +export interface ContentContainerPublicApiInput< + TField extends string = string, + TPath extends string = string, +> { + fields?: readonly TField[]; + path: TPath; +} + +export interface ContentContainerAdminInput { + navigation?: boolean; + path?: string; + permissionModule?: string; +} + +export interface ResolvedContentContainerPublicApi< + TField extends string = string, + TEnabled extends boolean = boolean, + TPath extends string = string, +> { + enabled: TEnabled; + fields: TField[]; + path: TPath; +} + +export interface ContentContainerSchemas { + create: z.ZodObject; + metadata: z.ZodObject; + metadataPatch: z.ZodObject; + move: z.ZodObject; + publicRecord: z.ZodObject; + record: z.ZodObject; + update: z.ZodObject; +} + +export interface ContentContainerDefinition< + TId extends string = string, + TFields extends ContentContainerFieldMap = ContentContainerFieldMap, + TPublicField extends string = string, + TPublicEnabled extends boolean = boolean, + TPublicPath extends string = string, +> { + admin: { + navigation: { enabled: boolean }; + path: string; + permissionModule: string; + }; + fields: TFields; + hierarchy: { enabled: boolean; maxDepth: number }; + id: TId; + kind: "contentContainer"; + ordering: { mode: ContentContainerOrderingMode }; + publicApi: ResolvedContentContainerPublicApi< + TPublicField, + TPublicEnabled, + TPublicPath + >; + schemas: ContentContainerSchemas; +} + +export type AnyContentContainerDefinition = ContentContainerDefinition; + +type FieldsOf = TDefinition extends { + fields: infer TFields extends ContentContainerFieldMap; +} + ? TFields + : ContentContainerFieldMap; + +type PublicFieldsOf = TDefinition extends { + publicApi: { fields: (infer TField extends string)[] }; +} + ? TField + : never; + +export type ContentContainerMetadata = Prettify<{ + [K in keyof FieldsOf]: ContentFieldValue< + FieldsOf[K] + > | null; +}>; + +export type ContentContainerMetadataInput = ContentValuesOf< + FieldsOf +>; + +export type ContentContainerPublicMetadata = Prettify< + Pick< + ContentContainerMetadata, + keyof ContentContainerMetadata & PublicFieldsOf + > +>; + +interface ContentContainerColumns { + depth: number; + id: number; + name: string; + parentId: null | number; + position: number; + slug: string; +} + +export type ContentContainerRecord< + TDefinition = AnyContentContainerDefinition, +> = Prettify< + ContentContainerColumns & { + createdAt: Date; + metadata: ContentContainerMetadata; + updatedAt: Date; + } +>; + +export type ContentContainerPublicRecord< + TDefinition = AnyContentContainerDefinition, +> = Prettify< + ContentContainerColumns & { + metadata: ContentContainerPublicMetadata; + } +>; + +export interface ContentContainerCreateInput< + TDefinition = AnyContentContainerDefinition, +> { + metadata?: ContentContainerMetadataInput; + name: string; + parentId?: null | number; + position?: number; + slug?: string; +} + +export interface ContentContainerUpdateInput< + TDefinition = AnyContentContainerDefinition, +> { + metadata?: Partial>; + name?: string; + slug?: string; +} + +export interface ContentContainerMoveInput { + parentId: null | number; + position: number; +} diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index 8fd3841b6e..2c93ba5b25 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -1,5 +1,6 @@ import type { AnyContentTypeDefinition, + ContentContainerField, ContentFieldDescriptor, ContentFieldMap, ContentFileField, @@ -12,6 +13,7 @@ import { } from "../blocks/const"; import { parseBlockId } from "../blocks/namespace"; import { CONTENT_FIELD_NAME_PATTERN } from "./const"; +import { isContentContainerDefinition } from "./containers/guard"; import { editorialFields, publicationFields, @@ -55,6 +57,7 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { // the empty set is its default, and it has no column to default at all. if (fieldValue.kind === "file") return fieldValue.multiple; if (fieldValue.kind === "richText") return false; + if (fieldValue.kind === "container") return fieldValue.nullable; return fieldValue.defaultValue !== undefined; }; @@ -112,6 +115,7 @@ export const assertFieldName = ( const FIELD_KINDS = new Set([ "blocks", "boolean", + "container", "dateTime", "enum", "file", @@ -211,6 +215,48 @@ const assertFileField = ( } }; +const assertContainerField = ( + id: string, + name: string, + fieldValue: ContentContainerField, +): void => { + if (!isContentContainerDefinition(fieldValue.definition)) { + throw new ContentEngineError( + `Container field "${name}" has no container definition. Pass the object \`defineContentContainer\` returned: \`field.container({ definition: forumBoards })\`.`, + { contentTypeId: id }, + ); + } + + if ((fieldValue.localized as boolean | undefined) === true) { + throw new ContentEngineError( + `Container field "${name}" is \`localized: true\`. A record sits in the same container in every language.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.nullable === fieldValue.required) { + throw new ContentEngineError( + `Container field "${name}" is ${fieldValue.required ? "required but nullable" : "optional but not nullable"}. Build it with \`field.container()\`, which keeps the two in step.`, + { contentTypeId: id }, + ); + } +}; + +export const assertSingleContainerField = ( + id: string, + fields: ContentFieldMap, +): void => { + const containers = Object.entries(fields).filter( + ([, fieldValue]) => fieldValue.kind === "container", + ); + if (containers.length <= 1) return; + + throw new ContentEngineError( + `Content type declares ${containers.length} container fields (${containers.map(([name]) => name).join(", ")}). A record belongs to one container - keep one, and use a relation for anything else it points at.`, + { contentTypeId: id }, + ); +}; + export const assertField = ( id: string, name: string, @@ -222,6 +268,10 @@ export const assertField = ( assertFileField(id, name, fieldValue); } + if (fieldValue.kind === "container") { + assertContainerField(id, name, fieldValue); + } + if (!fieldValue.required && !fieldValue.nullable) { if (!hasWritableFallback(fieldValue)) { throw new ContentEngineError( diff --git a/packages/vitnode/src/content/define-public-write.test.ts b/packages/vitnode/src/content/define-public-write.test.ts index a2a9a173ac..04d43adfeb 100644 --- a/packages/vitnode/src/content/define-public-write.test.ts +++ b/packages/vitnode/src/content/define-public-write.test.ts @@ -6,10 +6,13 @@ import { testPostContentType, } from "@/tests/content-fixtures"; +import { defineContentContainer } from "./containers/define"; import { defineContentType } from "./define"; import { ContentEngineError } from "./errors"; import { field } from "./fields"; +const boards = defineContentContainer({ id: "test.boards" }); + const fields = { title: field.text({ required: true, maxLength: 200 }), slug: field.slug({ source: "title" }), @@ -42,6 +45,7 @@ const fields = { fields: { question: field.text({ required: true }) }, }), content: field.blocks(), + board: field.container({ definition: boards }), }; const ownership = { field: "author" }; @@ -185,6 +189,11 @@ describe("publicWrite configuration", () => { { ownership, update: { fields: [name] } }, new RegExp(`"${name}" of kind "${kind}", which cannot be written`), ]), + [ + "the container field in update", + { ownership, update: { fields: ["title", "board"] } }, + /update.fields includes the container field "board"/, + ], [ "a slug derived from another field", { ownership, update: { fields: ["slug"] } }, @@ -270,6 +279,27 @@ describe("publicWrite configuration", () => { }, ); + it("lets a member pick the container on create", () => { + const definition = define({ + publicApi: { + path: "threads", + fields: ["title", "slug", "board"], + filterableFields: ["board"], + }, + publicWrite: { + ownership, + create: { fields: ["title", "handle", "board"] }, + }, + }); + + expect(definition.publicWrite.create.fields).toEqual([ + "title", + "handle", + "board", + ]); + expect(definition.publicApi.filterableFields).toEqual(["board"]); + }); + it("needs a public API", () => { expect(() => define({ diff --git a/packages/vitnode/src/content/define-public-write.ts b/packages/vitnode/src/content/define-public-write.ts index 1e58d05cd2..b0c5ed3fda 100644 --- a/packages/vitnode/src/content/define-public-write.ts +++ b/packages/vitnode/src/content/define-public-write.ts @@ -385,6 +385,17 @@ export const resolvePublicWrite = ( own?: unknown; }; const list = listOf(id, "publicWrite.update.fields", names); + const container = list.find( + name => + (fields[name] as ContentFieldDescriptor | undefined)?.kind === + "container", + ); + if (container !== undefined) { + throw new ContentEngineError( + `publicWrite.update.fields includes the container field "${container}". A member picks a container when they create a record; moving it afterwards is a staff edit (\`can_edit\` in the AdminCP), so an author cannot drop their topic into a board they could not post in.`, + { contentTypeId: id }, + ); + } for (const name of list) { assertWritableField( id, diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 4fdac5df00..9418ab978b 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -10,6 +10,7 @@ import { CONTENT_REVISION_MAX_RETENTION, CONTENT_REVISION_MIN_RETENTION, } from "./const"; +import { defineContentContainer } from "./containers/define"; import { defineContentType } from "./define"; import { ContentEngineError } from "./errors"; import { field } from "./fields"; @@ -106,6 +107,73 @@ describe("defineContentType", () => { }); }); + describe("container fields", () => { + const boards = defineContentContainer({ id: "test.boards" }); + + it.each([true, false])("accepts a container with required %s", required => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + board: field.container({ definition: boards, required }), + }, + }), + ).not.toThrow(); + }); + + it.each([ + [ + "a definition that is not a container collection", + { board: field.container({ definition: {} as typeof boards }) }, + /Container field "board" has no container definition/, + ], + [ + "a localized container", + { + board: { + ...field.container({ definition: boards }), + localized: true, + }, + }, + /Container field "board" is `localized: true`/, + ], + [ + "an optional container that is not nullable", + { + board: { + ...field.container({ definition: boards }), + nullable: false, + }, + }, + /"board" is optional but not nullable/, + ], + [ + "a required container that is nullable", + { + board: { + ...field.container({ definition: boards, required: true }), + nullable: true, + }, + }, + /"board" is required but nullable/, + ], + [ + "two container fields", + { + board: field.container({ definition: boards }), + section: field.container({ definition: boards }), + }, + /declares 2 container fields \(board, section\)/, + ], + ])("rejects %s", (_label, fields, message) => { + expect(() => + define({ + fields: { title: field.text({ required: true }), ...fields }, + }), + ).toThrow(message); + }); + }); + // `ON DELETE SET NULL` on a NOT NULL column is accepted by Postgres at // CREATE TABLE time and only blows up years later, when someone finally // deletes a referenced row. diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index e55a9e88ee..26f3d38ca5 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -59,6 +59,7 @@ import { assertFieldAiAssist, assertFieldKind, assertFieldName, + assertSingleContainerField, assertSlugSources, bindSelfRelations, } from "./define-fields"; @@ -396,6 +397,7 @@ export const defineContentType = < } assertSlugSources(id, fieldMap); + assertSingleContainerField(id, fieldMap); assertFieldAiAssist(id, fieldMap); // First, because every resolver below is stated in terms of what it produces: diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index 107c8bea75..08111c440c 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -1,8 +1,10 @@ import type { BlockAllowedSpec } from "../blocks/types"; +import type { AnyContentContainerDefinition } from "./containers/types"; import type { AnyContentTypeDefinition, ContentBlocksField, ContentBooleanField, + ContentContainerField, ContentDateTimeField, ContentEnumField, ContentFieldAiAssist, @@ -378,9 +380,28 @@ const blocks = < required: false, }); +const container = < + const TDefinition extends AnyContentContainerDefinition, + TRequired extends boolean = false, +>(args: { + definition: TDefinition; + description?: string; + required?: TRequired; +}): ContentContainerField => { + const required = (args.required ?? false) as TRequired; + + return { + ...args, + kind: "container", + nullable: !required as TRequired extends true ? false : true, + required, + }; +}; + export const field = { blocks, boolean, + container, dateTime, enum: enumField, file, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 17383a8ed3..abd72d591c 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -1,3 +1,11 @@ +export { + contentContainerAdminHref, + contentContainerI18nKeys, + contentContainerLabelsFrom, + contentContainerNamespace, + contentContainerNouns, +} from "./admin/containers"; +export type { ContentContainerLabels } from "./admin/containers"; export { contentEntityKey, contentI18nKeys, @@ -14,7 +22,9 @@ export { setContentFrontendRegistry, } from "./admin/registry"; export type { + ContentContainerFrontendRegistry, ContentFrontendRegistry, + RegisteredFrontendContentContainer, RegisteredFrontendContentType, } from "./admin/registry"; @@ -51,6 +61,7 @@ export { } from "./admin/upload"; export type { ContentUploadRejection } from "./admin/upload"; export { + contentContainerTag, contentDeliveryRedirectTag, contentDeliverySitemapTag, contentDeliveryTag, @@ -182,6 +193,58 @@ export { isLocalizableFieldKind, RESERVED_FILTER_KEYS, } from "./const"; +export { + ContentContainerAssignmentError, + contentContainerFieldOf, +} from "./containers/association"; +export type { ContentContainerFieldEntry } from "./containers/association"; +export { + CONTENT_CONTAINER_ABSOLUTE_MAX_DEPTH, + CONTENT_CONTAINER_ERROR_CODES, + CONTENT_CONTAINER_FIELD_KINDS, +} from "./containers/const"; +export { + defineContentContainer, + isContentContainerDefinition, +} from "./containers/define"; +export { ContentContainerError } from "./containers/errors"; +export { + contentContainerPermissionEntries, + validateContentContainers, +} from "./containers/registry"; +export type { RegisteredContentContainer } from "./containers/registry"; +export { + contentContainerAncestors, + contentContainerSubtreeIds, + contentContainerTree, + orderContentContainers, + planContentContainerCreate, + planContentContainerDelete, + planContentContainerMove, +} from "./containers/tree"; +export type { + ContentContainerPlacement, + ContentContainerPlan, + ContentContainerTreeNode, + ContentContainerTreeRow, + ContentContainerTreeRules, +} from "./containers/tree"; +export type { + AnyContentContainerDefinition, + ContentContainerCreateInput, + ContentContainerDefinition, + ContentContainerErrorCode, + ContentContainerFieldDescriptor, + ContentContainerFieldMap, + ContentContainerMetadata, + ContentContainerMetadataInput, + ContentContainerMoveInput, + ContentContainerOrderingMode, + ContentContainerPublicMetadata, + ContentContainerPublicRecord, + ContentContainerRecord, + ContentContainerUpdateInput, +} from "./containers/types"; export { defineContentType } from "./define"; export { contentDeliveryAlternateHref, @@ -551,6 +614,7 @@ export type { SchedulableContentTypeDefinition, SearchableContentTypeDefinition, } from "./types"; +export type { ContentContainerField } from "./types"; export { contentVisibilityTransition, isContentHidden } from "./visibility"; export type { ContentVisibilityAction, diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index eef7a71974..9f9ef3ff67 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -248,6 +248,13 @@ export const resolveContentIndexes = ({ fieldValue.kind === "user", ) .map(([name]) => named(tableName, { on: [name] })), + ...fieldEntries + .filter(([, fieldValue]) => fieldValue.kind === "container") + .map(([name]) => + named(tableName, { + on: publication ? [name, "publishedAt"] : [name], + }), + ), ...CONTENT_SYSTEM_FIELDS.filter(name => name !== "id").map(name => named(tableName, { on: [name] }), ), diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 37373fcb5d..cb2857bac3 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -261,6 +261,8 @@ const baseSelectSchema = ( return blocksSelectSchema(); case "boolean": return z.boolean(); + case "container": + return referenceSchema(); case "dateTime": return z.date(); case "enum": @@ -356,6 +358,7 @@ const applyPresence = ( if ( fieldValue.kind !== "blocks" && + fieldValue.kind !== "container" && fieldValue.kind !== "dateTime" && // A file field has no default and cannot have one: a `core_files.id` in a // definition would name a different row on every installation. @@ -535,6 +538,15 @@ const updateShape = ( }), ); +export const contentFieldPatchObject = ( + fields: ContentFieldMap, +): z.ZodObject => + z.strictObject(updateShape(fields, Object.keys(fields))); + +export const contentFieldSelectSchema = ( + fieldValue: ContentFieldDescriptor, +): z.ZodType => applyNullable(baseSelectSchema(fieldValue), fieldValue); + export const contentFieldUpdateSchema = ( fields: ContentFieldMap, name: string, @@ -576,6 +588,8 @@ const filterShape = ( switch (fieldValue.kind) { case "boolean": return [name, z.enum(["true", "false"]).optional()]; + case "container": + return [name, z.coerce.number().int().positive().optional()]; case "enum": return [name, z.enum(fieldValue.values).optional()]; case "number": diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 1aa3fd7781..7c5bfd7b56 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -190,6 +190,21 @@ export const buildContentColumn = ({ defaultValue: fieldValue.defaultValue, nullable, }); + case "container": { + if (!reference) { + throw new ContentEngineError( + `Field "${name}" is a container but the \`core_content_containers\` column was not resolved. This is an internal error.`, + { contentTypeId }, + ); + } + + const column = integer().references(reference, { + onDelete: "restrict", + onUpdate: "cascade", + }); + + return nullable ? column : column.notNull(); + } case "dateTime": { const column = nullable ? timestamp() : timestamp().notNull(); diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index ddb9f04f83..3d6b32a7b3 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -53,6 +53,8 @@ import { CONTENT_SYSTEM_FIELDS, CONTENT_VISIBILITY_FIELDS, } from "../const"; +import { ContentContainerAssignmentError } from "../containers/association"; +import { resolveContentContainerAssignment } from "../containers/server/assignment"; import { ContentEngineError, ContentRevisionNotRestorable, @@ -955,6 +957,7 @@ export const createContentEditorialService = < // assignment, and an upload that succeeded for one field proves nothing // about another. await assertContentFileReferences(c, definition, parsed, tx); + await resolveContentContainerAssignment(tx, definition, parsed); const [row] = await tx .insert(table) @@ -1156,8 +1159,14 @@ export const createContentEditorialService = < await assertContentFileReferences(c, definition, patch, tx, { existing: async () => Promise.resolve(patch), }); + await resolveContentContainerAssignment(tx, definition, patch); } catch (error) { - if (!(error instanceof ContentFileReferenceError)) throw error; + if ( + !(error instanceof ContentFileReferenceError) && + !(error instanceof ContentContainerAssignmentError) + ) { + throw error; + } throw new ContentRevisionNotRestorable({ contentTypeId, @@ -1302,6 +1311,7 @@ export const createContentEditorialService = < ...(await store?.load(id, tx, galleryFieldNames)), }), }); + await resolveContentContainerAssignment(tx, definition, patch); // The version guard runs **first**, before a single junction or child // row is touched. That ordering is the whole concurrency story: a writer diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 7ff38a3425..42f3339f63 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -17,6 +17,7 @@ import { CONTENT_DUPLICATE_CODES, CONTENT_UNPROCESSABLE_CODES, } from "../const"; +import { ContentContainerAssignmentError } from "../containers/association"; import { ContentDeliverySlugReserved, ContentDuplicateSlugConflict, @@ -163,6 +164,14 @@ export const rethrowAsHttpError = ( }); } + if (error instanceof ContentContainerAssignmentError) { + throw contentFileRejected({ + code: error.code, + field: error.field, + message: error.detail, + }); + } + // Written for the client on purpose - "provide the slug explicitly" is // useless if it never leaves the server. if (error instanceof ContentInputError) { diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 3f771388f6..3613c58d20 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -1,4 +1,20 @@ export { escapeLikePattern } from "../../lib/api/like-pattern"; +export { + buildContentContainerAdminRoutes, + zodContentContainerError, +} from "../containers/server/admin-routes"; +export { resolveContentContainerAssignment } from "../containers/server/assignment"; +export type { ResolvedContentContainerContext } from "../containers/server/assignment"; +export { buildContentContainerPublicRoutes } from "../containers/server/public-routes"; +export { + contentContainerReferencingModels, + contentContainerUsageFrom, + createContentContainerService, +} from "../containers/server/service"; +export type { + ContentContainerService, + ContentContainerUsageCheck, +} from "../containers/server/service"; export { CONTENT_SYSTEM_ACTOR, resolveContentActor } from "./actor"; export { buildContentColumn, diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts index d99b67c1a5..a23c0d95a7 100644 --- a/packages/vitnode/src/content/server/module.ts +++ b/packages/vitnode/src/content/server/module.ts @@ -1,7 +1,9 @@ import type { BuildModuleReturn } from "../../api/lib/module"; +import type { AnyContentContainerDefinition } from "../containers/types"; import type { AnyContentModel } from "./model"; import { buildModule } from "../../api/lib/module"; +import { buildContentContainerAdminRoutes } from "../containers/server/admin-routes"; import { buildContentRoutes } from "./routes"; import { createContentLocalizedSearchIndexer, @@ -10,9 +12,11 @@ import { import { assertContentReferences } from "./table"; export const buildContentAdminModule =

({ + containers = [], contentTypes, pluginId, }: { + containers?: AnyContentContainerDefinition[]; contentTypes: AnyContentModel[]; pluginId: P; }): BuildModuleReturn, "content"> => { @@ -29,11 +33,20 @@ export const buildContentAdminModule =

({ }); }); + const containerModules = containers.map(definition => + buildModule({ + pluginId, + name: definition.admin.permissionModule, + routes: buildContentContainerAdminRoutes(definition, { pluginId }), + }), + ); + return buildModule({ pluginId, name: "content", routes: [], - modules, + modules: [...modules, ...containerModules], + contentContainers: containers, contentTypes: contentTypes.map(model => model.definition), // The models themselves, not just the definitions. Background work - the // scheduled-publication task - needs the table and the editorial service, diff --git a/packages/vitnode/src/content/server/public-module.ts b/packages/vitnode/src/content/server/public-module.ts index 84c8f4a4d8..0bede64a33 100644 --- a/packages/vitnode/src/content/server/public-module.ts +++ b/packages/vitnode/src/content/server/public-module.ts @@ -1,8 +1,10 @@ import type { BuildModuleReturn } from "../../api/lib/module"; +import type { AnyContentContainerDefinition } from "../containers/types"; import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; import { buildModule } from "../../api/lib/module"; +import { buildContentContainerPublicRoutes } from "../containers/server/public-routes"; import { buildContentPublicRoutes } from "./public-routes"; export type ContentPublicRoutes< @@ -20,26 +22,50 @@ export type ContentPublicModuleOf

= Model extends { : never : never; +export type ContentContainerPublicRoutes< + P extends string, + TDefinition extends AnyContentContainerDefinition, +> = ReturnType>; + +export type ContentContainerPublicModuleOf< + P extends string, + TDefinition, +> = TDefinition extends AnyContentContainerDefinition + ? TDefinition extends { + publicApi: { enabled: true; path: infer Path extends string }; + } + ? BuildModuleReturn> + : never + : never; + export type ContentPublicModule< P extends string, Models extends readonly AnyContentModel[], + Containers extends readonly AnyContentContainerDefinition[] = readonly [], > = BuildModuleReturn< P, "content", [], - ContentPublicModuleOf[] + ( + | ContentContainerPublicModuleOf + | ContentPublicModuleOf + )[] >; export const buildContentPublicModule = < const P extends string, const Models extends readonly AnyContentModel[], + const Containers extends readonly AnyContentContainerDefinition[] = + readonly [], >({ + containers = [] as unknown as Containers, contentTypes, pluginId, }: { + containers?: Containers; contentTypes: Models; pluginId: P; -}): ContentPublicModule, Models> => { +}): ContentPublicModule, Models, Containers> => { const modules = contentTypes .filter(model => model.definition.publicApi.enabled) .map(model => @@ -49,13 +75,23 @@ export const buildContentPublicModule = < routes: buildContentPublicRoutes(model, { pluginId }), }), ); + const containerModules = containers + .filter(definition => definition.publicApi.enabled) + .map(definition => + buildModule({ + pluginId, + name: definition.publicApi.path, + routes: buildContentContainerPublicRoutes(definition, { pluginId }), + }), + ); return buildModule({ pluginId, name: "content", routes: [], - modules, + modules: [...modules, ...containerModules], + contentContainers: [...containers], // No `contentTypes` - see the warning above. publicContentTypes: contentTypes.map(model => model.definition), - }) as ContentPublicModule, Models>; + }) as ContentPublicModule, Models, Containers>; }; diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts index 3e3949a447..6d9f87fb22 100644 --- a/packages/vitnode/src/content/server/public-routes.test.ts +++ b/packages/vitnode/src/content/server/public-routes.test.ts @@ -1,10 +1,16 @@ +import type { Context } from "hono"; + import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; import { describe, expect, it, vi } from "vitest"; +import { core_content_containers } from "@/database/content-containers"; import { testCategoryContentType, testPostContentType, + testThreadContentType, } from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; import { createContentModel } from "./model"; import { buildContentPublicModule } from "./public-module"; @@ -153,6 +159,153 @@ describe("public list route", () => { }); }); +describe("public list filtered by container", () => { + const threads = createContentModel(testThreadContentType); + const PUBLISHED = new Date("2026-08-01T09:00:00.000Z"); + + const BOARD = { + ELSEWHERE: 9, + GENERAL: 1, + MISSING: 99, + NEWS: 3, + OFF_TOPIC: 2, + RELEASES: 5, + } as const; + const THREAD_IN = { + [BOARD.GENERAL]: 11, + [BOARD.NEWS]: 12, + [BOARD.RELEASES]: 13, + [BOARD.OFF_TOPIC]: 14, + }; + + const board = ( + id: number, + parentId: null | number, + definitionId: string = testThreadContentType.fields.board.definition.id, + ) => ({ + createdAt: PUBLISHED, + definitionId, + id, + metadata: {}, + name: `Board ${id}`, + parentId, + pluginId: PLUGIN_ID, + position: 0, + slug: `board-${id}`, + updatedAt: PUBLISHED, + }); + + const app = (() => { + const memory = createMemoryDb([ + [ + core_content_containers, + [ + board(BOARD.GENERAL, null), + board(BOARD.NEWS, BOARD.GENERAL), + board(BOARD.RELEASES, BOARD.NEWS), + board(BOARD.OFF_TOPIC, null), + board(BOARD.ELSEWHERE, null, "test.sections"), + ], + ], + [ + threads.table, + Object.entries(THREAD_IN).map(([boardId, id]) => ({ + author: 1, + board: Number(boardId), + createdAt: PUBLISHED, + id, + publishedAt: PUBLISHED, + slug: `thread-${id}`, + status: "published", + title: `Thread ${id}`, + updatedAt: PUBLISHED, + })), + ], + ]); + const instance = new OpenAPIHono(); + instance.use("*", async (c, next) => { + c.set("db", memory.db as unknown as Context["var"]["db"]); + await next(); + }); + instance.onError((error, c) => + error instanceof HTTPException + ? error.getResponse() + : c.json({ error: String(error) }, 500), + ); + for (const { handler, route } of buildContentPublicRoutes(threads, { + pluginId: PLUGIN_ID, + })) { + instance.openapi(route, handler); + } + + return instance; + })(); + + const listed = async (query: string) => { + const response = await app.request(`/${query}`); + expect(response.status).toBe(200); + const body = (await response.json()) as { edges: { id: number }[] }; + + return body.edges.map(edge => edge.id).sort((a, b) => a - b); + }; + + it.each([ + ["one container", `?board=${BOARD.GENERAL}`, [THREAD_IN[BOARD.GENERAL]]], + [ + "a container and its subtree", + `?board=${BOARD.GENERAL}&includeDescendants=true`, + [ + THREAD_IN[BOARD.GENERAL], + THREAD_IN[BOARD.NEWS], + THREAD_IN[BOARD.RELEASES], + ], + ], + [ + "a subtree further down", + `?board=${BOARD.NEWS}&includeDescendants=true`, + [THREAD_IN[BOARD.NEWS], THREAD_IN[BOARD.RELEASES]], + ], + [ + "one container when includeDescendants is false", + `?board=${BOARD.NEWS}&includeDescendants=false`, + [THREAD_IN[BOARD.NEWS]], + ], + [ + "everything when no container is named", + "?includeDescendants=true", + Object.values(THREAD_IN), + ], + ])("lists %s", async (_label, query, expected) => { + await expect(listed(query)).resolves.toEqual( + [...expected].sort((a, b) => a - b), + ); + }); + + it.each([ + ["does not exist", BOARD.MISSING], + ["belongs to another collection", BOARD.ELSEWHERE], + ])("is a 404 for a container that %s", async (_label, id) => { + expect((await app.request(`/?board=${id}`)).status).toBe(404); + expect( + (await app.request(`/?board=${id}&includeDescendants=true`)).status, + ).toBe(404); + }); + + it("gives a content type without containers no includeDescendants", async () => { + const { app: postsApp, service } = harness(); + service.findMany.mockResolvedValue(emptyPage); + + const response = await postsApp.request( + "/?includeDescendants=true&category=3", + ); + + expect(response.status).toBe(200); + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ filters: { category: 3 } }), + ); + }); +}); + describe("public detail route", () => { it("returns a published row", async () => { const { app, service } = harness(); diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 212a220c5f..089f6ab8c0 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -27,10 +27,13 @@ import { zodPaginationQuery, } from "../../api/lib/with-pagination"; import { contentTypeName } from "../admin/labels"; +import { contentContainerTag } from "../cache"; import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PUBLIC_MAX_PAGE_SIZE, } from "../const"; +import { contentContainerFieldOf } from "../containers/association"; +import { createContentContainerService } from "../containers/server/service"; import { ContentEngineError } from "../errors"; import { parseContentId } from "../ids"; import { resolveContentPublicLocale } from "../locale"; @@ -103,7 +106,18 @@ export const buildContentPublicRoutes = < orderBy: z.enum(orderable).optional(), search: z.string().optional(), }); - const listQuery = paginationQuery.extend(schemas.publicFilters.shape); + const containerField = contentContainerFieldOf(definition); + const containerFilter = + containerField && + definition.publicApi.filterableFields.includes(containerField.name) + ? containerField + : null; + const listQuery = paginationQuery.extend({ + ...schemas.publicFilters.shape, + ...(containerFilter + ? { includeDescendants: z.enum(["true", "false"]).optional() } + : {}), + }); const localeOnlyQuery = z.object(localeQuery); const notFound = () => @@ -332,6 +346,10 @@ export const buildContentPublicRoutes = < description: `Up to ${CONTENT_PUBLIC_MAX_PAGE_SIZE} published ${name} records`, }, 400: { description: "Invalid query parameters" }, + 404: { + description: + "The requested locale is not served, or the container filter names a container that does not exist", + }, }, }, handler: async c => { @@ -346,6 +364,27 @@ export const buildContentPublicRoutes = < raw, ) as ContentPublicFilterInput; + if (containerFilter) { + const requested = (filters as Record)[ + containerFilter.name + ]; + if (typeof requested === "number") { + const containers = createContentContainerService(c, { + definition: containerFilter.field.definition, + pluginId, + }); + const ids = await containers.descendantIds(requested); + if (!ids) { + throw new HTTPException(404, { message: "Container not found." }); + } + if (raw.includeDescendants === "true") { + (filters as Record)[containerFilter.name] = { + in: ids, + }; + } + } + } + // An explicit locale naming no language this install serves is the same // 404 the detail route answers, not an empty list: an empty list would say // "this language has no articles", which is a different and untrue thing. @@ -366,7 +405,12 @@ export const buildContentPublicRoutes = < query: { cursor, first, last, page, search }, }); - setPageCacheTags(c, contentListPageCacheTags(definition)); + setPageCacheTags(c, [ + ...contentListPageCacheTags(definition), + ...(containerFilter + ? [contentContainerTag(containerFilter.field.definition.id)] + : []), + ]); return c.json(data, 200, localeHeaders(resolved.locale, resolved.source)); }, diff --git a/packages/vitnode/src/content/server/public-write-routes.test.ts b/packages/vitnode/src/content/server/public-write-routes.test.ts index 1ccaff7b1e..d31cd3b1fd 100644 --- a/packages/vitnode/src/content/server/public-write-routes.test.ts +++ b/packages/vitnode/src/content/server/public-write-routes.test.ts @@ -6,10 +6,12 @@ import { HTTPException } from "hono/http-exception"; import { afterEach, describe, expect, it, vi } from "vitest"; import { core_content_revisions } from "@/database/content"; +import { core_content_containers } from "@/database/content-containers"; import { createTestCache } from "@/tests/cache"; import { testEditorialTopicContentType, testForumContentType, + testThreadContentType, testTopicContentType, } from "@/tests/content-fixtures"; import { createMemoryDb } from "@/tests/memory-db"; @@ -614,6 +616,137 @@ describe("editorial content types", () => { }); }); +describe("a container field", () => { + const threads = createContentModel(testThreadContentType); + const BOARD = { ELSEWHERE: 9, GENERAL: 1, MISSING: 99 } as const; + const THREAD = 20; + + const board = (id: number, slug: string, definitionId: string) => ({ + createdAt: LIVE, + definitionId, + id, + metadata: {}, + name: slug, + parentId: null, + pluginId: PLUGIN_ID, + position: 0, + slug, + updatedAt: LIVE, + }); + + const threadHarness = () => + harness(threads as never, [ + [ + core_content_containers, + [ + board( + BOARD.GENERAL, + "general", + testThreadContentType.fields.board.definition.id, + ), + board(BOARD.ELSEWHERE, "elsewhere", "test.sections"), + ], + ], + [ + threads.table, + [ + { + author: OWNER, + board: BOARD.GENERAL, + createdAt: LIVE, + id: THREAD, + publishedAt: LIVE, + slug: "first-thread", + status: "published", + title: "First thread", + updatedAt: LIVE, + }, + ], + ], + ]); + + it("creates a record inside the container the member picked", async () => { + const { rows, send } = threadHarness(); + + const response = await send("POST", "/", { + body: { values: { board: BOARD.GENERAL, title: "Hello world" } }, + user: OWNER, + }); + + expect(response.status).toBe(201); + const record = (await response.json()) as { id: number }; + expect(rows().find(row => row.id === record.id)).toMatchObject({ + author: OWNER, + board: BOARD.GENERAL, + }); + }); + + it.each([ + ["does not exist", BOARD.MISSING], + ["belongs to another collection", BOARD.ELSEWHERE], + ])( + "refuses a container that %s, on that field", + async (_label, container) => { + const { events, rows, send } = threadHarness(); + const before = rows(); + + const response = await send("POST", "/", { + body: { values: { board: container, title: "Hello world" } }, + user: OWNER, + }); + + expect(response.status).toBe(400); + await expect(rejection(response)).resolves.toEqual({ + code: CONTENT_PUBLIC_WRITE_CODES.invalidInput, + contentTypeId: threads.definition.id, + issues: [ + { + code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.unavailable, + field: "board", + message: expect.any(String), + }, + ], + }); + expect(rows()).toEqual(before); + expect(events()).toEqual([]); + }, + ); + + it("keeps the record in its container on an update", async () => { + const { rows, send } = threadHarness(); + + const response = await send("PATCH", `/${THREAD}`, { + body: { values: { title: "Renamed thread" } }, + user: OWNER, + }); + + expect(response.status).toBe(200); + expect(rows().find(row => row.id === THREAD)).toMatchObject({ + board: BOARD.GENERAL, + title: "Renamed thread", + }); + }); + + it("never lets an author move their record to another container", async () => { + const { events, rows, send } = threadHarness(); + const before = rows(); + + const response = await send("PATCH", `/${THREAD}`, { + body: { values: { board: BOARD.GENERAL, title: "Renamed thread" } }, + user: OWNER, + }); + + expect(response.status).toBe(400); + expect((await rejection(response)).issues).toContainEqual({ + code: CONTENT_PUBLIC_WRITE_ISSUE_CODES.notWritable, + field: "board", + message: expect.any(String), + }); + expect(rows()).toEqual(before); + expect(events()).toEqual([]); + }); +}); + describe("generated surface", () => { const operations = (app: OpenAPIHono) => Object.fromEntries( diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts index a155ecdc8d..643bc33d66 100644 --- a/packages/vitnode/src/content/server/public-write-service.ts +++ b/packages/vitnode/src/content/server/public-write-service.ts @@ -8,6 +8,7 @@ import type { Context } from "hono"; import { eq, getColumns, getColumnTable, inArray } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; +import type { ResolvedContentContainerContext } from "../containers/server/assignment"; import type { ContentId, ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, @@ -21,6 +22,8 @@ import type { ContentModel } from "./model"; import type { ContentPublicWriteActor } from "./public-write-authorization"; import type { ContentDatabase } from "./service"; +import { ContentContainerAssignmentError } from "../containers/association"; +import { resolveContentContainerAssignment } from "../containers/server/assignment"; import { ContentEngineError, ContentPublicWriteDenied, @@ -54,6 +57,7 @@ export interface ContentPublicWriteService { export interface ContentPublicationPolicyInput { actor: ContentPublicWriteActor; + container: null | ResolvedContentContainerContext; definition: AnyContentTypeDefinition; operation: "create"; } @@ -194,6 +198,26 @@ export const createContentPublicWriteService = < } }; + const resolveContainer = async ( + tx: ContentDatabase, + values: Record, + ): Promise => { + try { + return ( + (await resolveContentContainerAssignment(tx, definition, values)) ?? + null + ); + } catch (error) { + if (!(error instanceof ContentContainerAssignmentError)) throw error; + + throw new ContentPublicWriteInputError({ + contentTypeId, + field: error.field, + message: error.detail, + }); + } + }; + const lockSelection = (): Record => ({ id: columns.id, publishedAt: columns.publishedAt, @@ -328,19 +352,26 @@ export const createContentPublicWriteService = < } const actor = requireActor(); const parsed = schemas.create.parse(values) as Record; - const status = resolveContentPublicationPolicy({ - actor, - definition, - operation: "create", - }); const stamped = { ...parsed, [ownerField]: actor.userId }; + const prepare = async (tx: ContentDatabase) => { + await assertPublicRelationTargets(tx, parsed); + const container = await resolveContainer(tx, parsed); + + return resolveContentPublicationPolicy({ + actor, + container, + definition, + operation: "create", + }); + }; + if (editorial) { const service = editorialService(); const { created, published } = await c .get("db") .transaction(async tx => { - await assertPublicRelationTargets(tx, parsed); + const status = await prepare(tx); const created = await service.create(stamped as never, { actor, tx, @@ -359,17 +390,17 @@ export const createContentPublicWriteService = < } const service = model.service(c); - const row = await c.get("db").transaction(async tx => { - await assertPublicRelationTargets(tx, parsed); + const { row, status } = await c.get("db").transaction(async tx => { + const status = await prepare(tx); const created = await service.create(stamped as never, { tx }); - if (status !== "published") return created; + if (status !== "published") return { row: created, status }; const result = await publicationMethods(definition, service).publish( created.id as never, { tx }, ); - return result?.row ?? created; + return { row: result?.row ?? created, status }; }); await plainEffects("create", row, { diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index 426430cc43..c9269d2a65 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -1,7 +1,16 @@ import type { SQL } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; -import { and, eq, ilike, isNotNull, isNull, or, sql } from "drizzle-orm"; +import { + and, + eq, + ilike, + inArray, + isNotNull, + isNull, + or, + sql, +} from "drizzle-orm"; import type { ContentFieldDescriptor, @@ -204,6 +213,19 @@ export const buildFilterCondition = ({ continue; } + if ( + fieldValue.kind === "container" && + typeof raw === "object" && + Array.isArray((raw as { in?: unknown }).in) + ) { + const ids = (raw as { in: unknown[] }).in.filter( + (id): id is number => + typeof id === "number" && Number.isSafeInteger(id), + ); + conditions.push(ids.length > 0 ? inArray(column, ids) : eq(column, 0)); + continue; + } + conditions.push(eq(column, filterValue(fieldValue, raw))); } diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 8a2c84347e..80031e3e6a 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -57,6 +57,7 @@ import { CONTENT_SYSTEM_FIELDS, CONTENT_VISIBILITY_FIELDS, } from "../const"; +import { resolveContentContainerAssignment } from "../containers/server/assignment"; import { ContentEngineError } from "../errors"; import { contentRelationStrategy, requireContentId } from "../ids"; import { partitionContentFields } from "../localization"; @@ -800,6 +801,7 @@ export const createContentService = < // against the field it is being written to. No statement at all for a // content type with no file fields. await assertContentFileReferences(c, definition, parsed, tx); + await resolveContentContainerAssignment(tx, definition, parsed); const [row] = await tx .insert(table) @@ -1120,6 +1122,10 @@ export const createContentService = < }), }); + if (changedPaths.length > 0) { + await resolveContentContainerAssignment(tx, definition, patch); + } + if (changedCollections.length > 0) await store?.write(tx, id, patch); // `updatedAt` has to move even when only a collection changed: it is diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 3aa1c232ca..390336fc57 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -25,6 +25,7 @@ import type { ContentTableFor, } from "./types"; +import { core_content_containers } from "../../database/content-containers"; import { core_files } from "../../database/files"; import { core_users } from "../../database/users"; import { @@ -153,6 +154,14 @@ const resolveReference = ( () => core_files.id, ); } + if (fieldValue.kind === "container") { + return checkedReference( + contentTypeId, + name, + () => getTableName(core_content_containers), + () => core_content_containers.id, + ); + } if (fieldValue.kind !== "relation") return undefined; const thunk = references[name]; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 7266d015a8..22c07382c9 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -73,7 +73,7 @@ type BaseBuilderFor = TField extends { kind: "relation" } ? PgVarcharBuilder> : TField extends { integer: false; kind: "number" } ? PgDoublePrecisionBuilder - : TField extends { kind: "number" | "user" } + : TField extends { kind: "container" | "number" | "user" } ? PgIntegerBuilder : TField extends { kind: "textarea" } ? PgTextBuilder diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index acc2cb3814..1218b4a07e 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -20,6 +20,7 @@ import type { CONTENT_VISIBILITY_FIELDS, CONTENT_VISIBILITY_FILTERS, } from "./const"; +import type { AnyContentContainerDefinition } from "./containers/types"; import type { ContentFileDescriptor } from "./files"; import type { ContentId, @@ -231,6 +232,16 @@ export interface ContentUserField< ordered: TOrdered; } +export interface ContentContainerField< + TRequired extends boolean = boolean, + TDefinition extends AnyContentContainerDefinition = + AnyContentContainerDefinition, +> extends ContentFieldShared { + definition: TDefinition; + kind: "container"; + localized?: false; +} + interface ContentReferenceCollection { kind: "file" | "relation" | "user"; multiple: true; @@ -298,6 +309,7 @@ export interface ContentRepeatableField< export type ContentFieldDescriptor = | ContentBlocksField | ContentBooleanField + | ContentContainerField | ContentDateTimeField | ContentEnumField | ContentFileField @@ -368,7 +380,7 @@ type ScalarFieldValue = TField extends { kind: "boolean" } ? TValue : TField extends { kind: "relation" } ? ContentRelationIdOf - : TField extends { kind: "file" | "number" | "user" } + : TField extends { kind: "container" | "file" | "number" | "user" } ? number : TField extends { kind: "richText" } ? RichTextDocument @@ -383,7 +395,7 @@ type ScalarFieldInput = TField extends { kind: "boolean" } ? TValue : TField extends { kind: "relation" } ? ContentRelationIdOf - : TField extends { kind: "file" | "number" | "user" } + : TField extends { kind: "container" | "file" | "number" | "user" } ? number : TField extends { kind: "richText" } ? RichTextDocument diff --git a/packages/vitnode/src/database/content-containers.ts b/packages/vitnode/src/database/content-containers.ts new file mode 100644 index 0000000000..a932be0bbc --- /dev/null +++ b/packages/vitnode/src/database/content-containers.ts @@ -0,0 +1,47 @@ +import { + type AnyPgColumn, + camelCase, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +import { + CONTENT_CONTAINER_NAME_MAX_LENGTH, + CONTENT_CONTAINER_SLUG_MAX_LENGTH, +} from "../content/containers/const"; + +export const core_content_containers = camelCase.table.withRLS( + "core_content_containers", + t => ({ + id: t.serial().primaryKey(), + pluginId: t.varchar({ length: 255 }).notNull(), + definitionId: t.varchar({ length: 100 }).notNull(), + parentId: t + .integer() + .references((): AnyPgColumn => core_content_containers.id, { + onDelete: "restrict", + }), + name: t.varchar({ length: CONTENT_CONTAINER_NAME_MAX_LENGTH }).notNull(), + slug: t.varchar({ length: CONTENT_CONTAINER_SLUG_MAX_LENGTH }).notNull(), + position: t.integer().notNull().default(0), + metadata: t.jsonb().$type>().notNull().default({}), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + uniqueIndex("core_content_containers_definition_slug_key").on( + t.definitionId, + t.slug, + ), + index("core_content_containers_definition_parent_position_idx").on( + t.definitionId, + t.parentId, + t.position, + ), + index("core_content_containers_parent_idx").on(t.parentId), + ], +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 8013faa8b2..1ed95f10c2 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -3,6 +3,7 @@ import { defineRelations } from "drizzle-orm"; import * as admins from "./admins"; import * as ai from "./ai"; import * as content from "./content"; +import * as contentContainers from "./content-containers"; import * as cron from "./cron"; import * as dashboard from "./dashboard"; import * as files from "./files"; @@ -24,6 +25,7 @@ export const coreSchema = { ...admins, ...ai, ...content, + ...contentContainers, ...cron, ...dashboard, ...files, diff --git a/packages/vitnode/src/lib/admin-nav-source.test-d.ts b/packages/vitnode/src/lib/admin-nav-source.test-d.ts index 1f48781633..6f1a2f475f 100644 --- a/packages/vitnode/src/lib/admin-nav-source.test-d.ts +++ b/packages/vitnode/src/lib/admin-nav-source.test-d.ts @@ -13,7 +13,7 @@ describe("AdminNavPluginSource", () => { it("carries only what a sidebar reads", () => { expectTypeOf().toEqualTypeOf< - "admin" | "contentTypes" | "pluginId" + "admin" | "containers" | "contentTypes" | "pluginId" >(); }); diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 5a00dc685f..438d69c982 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -1,5 +1,6 @@ import type { PermissionsStaffArgs } from "../api/lib/permission-staff"; import type { ItemAutoFormComponentProps } from "../components/form/auto-form"; +import type { AnyContentContainerDefinition } from "../content/containers/types"; import type { ContentId } from "../content/ids"; import type { AnyContentTypeDefinition, @@ -31,8 +32,14 @@ export type AdminNavContentType = Pick< "definition" | "icon" >; +export interface ContentContainerFrontendRegistration { + definition: AnyContentContainerDefinition; + icon?: React.ReactNode; +} + export interface AdminNavPluginSource { admin?: { nav?: AdminNavDeclaration[] }; + containers?: ContentContainerFrontendRegistration[]; contentTypes?: AdminNavContentType[]; pluginId: string; } @@ -119,6 +126,7 @@ export interface ContentTypeFrontendRegistration { } export interface ContentFrontendPluginSource { + containers?: ContentContainerFrontendRegistration[]; contentTypes?: ContentTypeFrontendRegistration[]; pluginId: string; } @@ -163,6 +171,7 @@ export interface BuildPluginReturn

{ }; nav?: AdminNavDeclaration[]; }; + containers?: ContentContainerFrontendRegistration[]; contentTypes?: ContentTypeFrontendRegistration[]; /** * Every locale file this plugin ships, as literal `*.json` specifiers an diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index b0ef5fa2e6..63bef28a62 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1576,6 +1576,15 @@ "move_up": "Move entry {position} up", "position": "{label} {position} of {total}", "remove": "Remove entry {position}" + }, + "container": { + "placeholder": "Select a place…", + "none": "None", + "empty": "There is nowhere to file this yet. Create the first one in its collection, then come back.", + "loadFailed": "The list of places could not be loaded. Check your connection and try again.", + "forbidden": "You can't see this collection, so you can't choose a place in it. Ask an administrator for permission to view it.", + "unregistered": "This field points at a collection no installed plugin registers, so there is nothing to choose from.", + "retry": "Try again" } }, "conflict": { @@ -1756,6 +1765,101 @@ } } }, + "containers": { + "list": { + "label": "{name} tree", + "summary": "{count, plural, one {# item} other {# items}}", + "depth": "up to {max} levels deep", + "flat": "one level", + "sorted": "sorted A–Z", + "collapseAll": "Collapse all", + "expandAll": "Expand all", + "showChildren": "Show what's inside {name}", + "hideChildren": "Hide what's inside {name}", + "actions": "More actions for {name}", + "dragHandle": "Drag to move {name}", + "edit": "Edit", + "addChild": "Add inside", + "moveUp": "Move up", + "moveDown": "Move down", + "moveToTop": "Move to top level", + "moveInside": "Move inside…", + "delete": "Delete", + "moveSuccess": "Moved", + "moveSuccessDesc": "\"{name}\" is in its new place.", + "moveFailed": "Could not move \"{name}\"" + }, + "dnd": { + "instructions": "Press space bar or enter to pick up an item. Use the up and down arrow keys to move it, and the left and right arrow keys to change how deep it sits. Press space bar or enter again to drop it, or escape to cancel.", + "started": "Picked up {name}.", + "overRoot": "{name} would move to the top level{sorted, select, true {} other {, place {position}}}.", + "overInside": "{name} would move inside {parent}{sorted, select, true {} other {, place {position}}}.", + "overInvalid": "{name} can't go here. {reason}", + "droppedRoot": "Dropped {name} at the top level{sorted, select, true {} other {, place {position}}}.", + "droppedInside": "Dropped {name} inside {parent}{sorted, select, true {} other {, place {position}}}.", + "droppedInvalid": "{name} can't go there, so it stays where it was. {reason}", + "unchanged": "{name} stays where it was.", + "cancelled": "Moving cancelled. {name} is back where it was." + }, + "empty": { + "title": "No {name} yet", + "desc": "Create the first one, then nest more inside it to build your structure." + }, + "error": { + "title": "Could not load {name}", + "desc": "Something went wrong while reading the list. Check your connection and try again.", + "forbidden": "You don't have permission to view this collection.", + "retry": "Try again" + }, + "form": { + "name": "Name", + "slug": "Slug", + "slugDesc": "The address part. Leave it empty and it's made from the name.", + "parent": "Place it in", + "parentDesc": "The tree goes up to {max} levels deep.", + "parentRoot": "Top level", + "fields": "Details" + }, + "create": { + "title": "Create {name}", + "desc": "Add a new {name} to the tree.", + "submit": "Create", + "success": "Created", + "successDesc": "\"{name}\" is in the tree." + }, + "edit": { + "title": "Edit {name}", + "submit": "Save changes", + "success": "Saved", + "successDesc": "\"{name}\" has been updated." + }, + "delete": { + "title": "Delete {name}", + "desc": "Delete \"{name}\"? This can't be undone.", + "confirm": "Delete", + "cancel": "Cancel", + "success": "{name} deleted", + "successDesc": "\"{name}\" is gone from the tree.", + "failed": "Could not delete \"{name}\"" + }, + "errors": { + "cycle": "It can't go inside itself or inside something it contains.", + "definitionMismatch": "That belongs to a different collection.", + "hasChildren": "It still has items inside. Move or delete them first.", + "hasContent": "Content is still filed here. Move or delete that content first.", + "invalidInput": "Some of the values aren't valid. Check the form and try again.", + "invalidMetadata": "Some of the details don't match what this collection expects.", + "invalidPosition": "That position is outside the list. Refresh and try again.", + "limitReached": "This collection is full. Delete something before adding more.", + "maxDepth": "That would nest it deeper than this collection allows.", + "notFound": "It no longer exists - someone may have deleted it. Refresh the list.", + "parentNotFound": "The place you picked no longer exists. Refresh the list.", + "slugEmpty": "The slug needs at least one letter or digit.", + "slugTaken": "Another item in this collection already uses this slug.", + "forbidden": "You don't have permission to do that.", + "unknown": "Something went wrong. Try again in a moment." + } + }, "editor": { "title": "Editing page", "available_blocks": "Widgets", diff --git a/packages/vitnode/src/pages/admin/containers.tsx b/packages/vitnode/src/pages/admin/containers.tsx new file mode 100644 index 0000000000..d0bb89a3ab --- /dev/null +++ b/packages/vitnode/src/pages/admin/containers.tsx @@ -0,0 +1,48 @@ +import { notFound } from "@tanstack/react-router"; + +import type { ContentFrontendRegistry } from "@/content/admin/registry"; +import type { PluginRoutePageProps } from "@/routing"; +import type { ContentContainerRouteData } from "@/tanstack/admin/containers/route"; + +import { ContentContainerBreadcrumbContent } from "@/tanstack/admin/containers/breadcrumb"; +import { loadContentContainerRoute } from "@/tanstack/admin/containers/route"; +import { ContentContainerScreenContent } from "@/tanstack/admin/containers/screen"; +import { getContentRegistryLoader } from "@/tanstack/admin/content/registry-runtime"; +import { contentRouteSegments } from "@/tanstack/admin/content/route"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const registry: ContentFrontendRegistry = await getContentRegistryLoader()(); + +const ContentContainerPage = ({ + loaderData, +}: PluginRoutePageProps) => { + const entry = registry.containers.byId(loaderData.definitionId); + + if (!entry) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + + return ; +}; + +export const route = defineAdminRoute({ + load: async ({ context, params }) => + await loadContentContainerRoute({ + ...context, + registry, + segments: contentRouteSegments(params._splat), + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup( + function ContentContainerBreadcrumb({ loaderData }) { + return ; + }, + ), +}); + +export default ContentContainerPage; diff --git a/packages/vitnode/src/routes.test.ts b/packages/vitnode/src/routes.test.ts index 51cf5b41f5..1a9a0b2442 100644 --- a/packages/vitnode/src/routes.test.ts +++ b/packages/vitnode/src/routes.test.ts @@ -67,13 +67,15 @@ describe("core's route tree", () => { expect(new Set(keys).size).toBe(keys.length); }); - /** One catch-all, and it is the Content Engine's. */ - it("declares exactly one catch-all", () => { + it("declares only the Content Engine's catch-alls", () => { const splats = manifest.filter(route => route.segments.some(segment => segment.kind === "splat"), ); - expect(splats.map(route => route.path)).toEqual(["/admin/content/*"]); + expect(splats.map(route => route.path).sort()).toEqual([ + "/admin/containers/*", + "/admin/content/*", + ]); }); /** diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index f01a1040e3..45787d0fd1 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -392,4 +392,10 @@ export const routes = defineRoutes([ pendingComponent: TablePendingSkeleton, search: contentListRouteSearch, }), + + page("/admin/containers/*", { + area: "admin", + component: lazy(() => import("./pages/admin/containers")), + pendingComponent: TablePendingSkeleton, + }), ]); diff --git a/packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx new file mode 100644 index 0000000000..03df1b8eff --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/containers/breadcrumb.tsx @@ -0,0 +1,25 @@ +import type { ContentContainerRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { AdminBreadcrumb } from "../breadcrumb"; + +export type ContentContainerBreadcrumbProps = Partial< + Pick +>; + +export const ContentContainerBreadcrumbContent = ({ + adminPath, + namespaces, + title, +}: ContentContainerBreadcrumbProps) => { + if (adminPath === undefined || title === undefined) return null; + + return ( + + + + ); +}; diff --git a/packages/vitnode/src/tanstack/admin/containers/route.tsx b/packages/vitnode/src/tanstack/admin/containers/route.tsx new file mode 100644 index 0000000000..53306f524a --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/containers/route.tsx @@ -0,0 +1,108 @@ +import { notFound } from "@tanstack/react-router"; +import { createTranslator } from "use-intl"; + +import type { ContentLabelTranslator } from "@/content/admin/labels"; +import type { + ContentFrontendRegistry, + RegisteredFrontendContentContainer, +} from "@/content/admin/registry"; + +import { contentContainerLabelsFrom } from "@/content/admin/containers"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentContainerListQuery } from "@/views/admin/views/containers/container-query"; +import { contentContainerApiTarget } from "@/views/admin/views/containers/container-request"; + +import type { AdminScreenContext } from "../screen"; + +import { intlQueryOptions } from "../../i18n/query"; +import { requireAdminPermission } from "../screen"; + +export const ADMIN_CONTAINERS_NAMESPACES = [ + "core.global", + "core.containers", +] as const; + +export const contentContainerRouteNamespaces = (pluginId: string): string[] => [ + ...ADMIN_CONTAINERS_NAMESPACES, + pluginId, +]; + +export const contentContainerRouteQuery = ( + entry: Pick, +) => + contentContainerListQuery({ + definitionId: entry.definition.id, + target: contentContainerApiTarget(entry), + }); + +export interface ContentContainerRouteData { + adminPath: string; + definitionId: string; + description: string | undefined; + labels: { plural: string; singular: string; title: string }; + namespaces: string[]; + pluginId: string; + title: string; +} + +export const loadContentContainerRoute = async ({ + adminAccess, + locale, + queryClient, + registry, + segments, +}: AdminScreenContext & { + registry: ContentFrontendRegistry; + segments: readonly string[]; +}): Promise => { + const entry = registry.containers.byAdminPath(segments.join("/")); + + if (!entry) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + + requireAdminPermission(adminAccess, { + module: entry.definition.admin.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: entry.pluginId, + }); + + const namespaces = contentContainerRouteNamespaces(entry.pluginId); + + const [intl] = await Promise.all([ + queryClient.query({ + ...intlQueryOptions({ locale, namespaces }), + staleTime: "static", + }), + queryClient + .query({ ...contentContainerRouteQuery(entry), staleTime: "static" }) + .catch(() => undefined), + ]); + + const t = createTranslator({ + locale, + messages: intl.messages, + onError: () => undefined, + }) as unknown as ContentLabelTranslator; + + const labels = contentContainerLabelsFrom( + entry.definition, + entry.pluginId, + t, + ); + + return { + adminPath: entry.definition.admin.path, + definitionId: entry.definition.id, + description: labels.desc, + labels: { + plural: labels.plural, + singular: labels.singular, + title: labels.title, + }, + namespaces, + pluginId: entry.pluginId, + title: labels.title, + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/containers/screen.tsx b/packages/vitnode/src/tanstack/admin/containers/screen.tsx new file mode 100644 index 0000000000..d2af1fcecc --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/containers/screen.tsx @@ -0,0 +1,112 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { ContentLabelTranslator } from "@/content/admin/labels"; +import type { RegisteredFrontendContentContainer } from "@/content/admin/registry"; +import type { ContentContainerSave } from "@/views/admin/views/containers/container-form-content"; +import type { ContentContainerMutationResult } from "@/views/admin/views/containers/container-request"; + +import { contentContainerLabelsFrom } from "@/content/admin/containers"; +import { ContentContainerManager } from "@/views/admin/views/containers/container-manager"; +import { invalidateContentContainers } from "@/views/admin/views/containers/container-query"; +import { + contentContainerApiTarget, + createContentContainer, + deleteContentContainer, + moveContentContainer, + updateContentContainer, +} from "@/views/admin/views/containers/container-request"; + +import type { ContentContainerRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { contentContainerRouteQuery } from "./route"; + +export interface ContentContainerScreenProps extends ContentContainerRouteData { + entry: RegisteredFrontendContentContainer; +} + +const useContentContainerMutations = ( + entry: RegisteredFrontendContentContainer, +) => { + const queryClient = useQueryClient(); + + return React.useMemo(() => { + const target = contentContainerApiTarget(entry); + const settle = async (result: ContentContainerMutationResult) => { + if ("data" in result) { + await invalidateContentContainers(queryClient, entry.definition.id); + } + + return result; + }; + + const onSave: ContentContainerSave = async ({ body, id, parentId }) => + await settle( + id === undefined + ? await createContentContainer(target, { + ...body, + name: body.name ?? "", + parentId: parentId ?? null, + }) + : await updateContentContainer(target, id, body), + ); + + return { + onDelete: async (id: number) => + await settle(await deleteContentContainer(target, id)), + onMove: async ({ + id, + ...move + }: { + id: number; + parentId: null | number; + position: number; + }) => await settle(await moveContentContainer(target, id, move)), + onSave, + }; + }, [entry, queryClient]); +}; + +const ContentContainerScreenBody = ({ + entry, +}: { + entry: RegisteredFrontendContentContainer; +}) => { + const t = useTranslations(); + const query = useQuery(contentContainerRouteQuery(entry)); + const mutations = useContentContainerMutations(entry); + const labels = contentContainerLabelsFrom( + entry.definition, + entry.pluginId, + t as unknown as ContentLabelTranslator, + ); + + return ( + { + void query.refetch(); + }} + pluginId={entry.pluginId} + records={query.data} + {...mutations} + /> + ); +}; + +export const ContentContainerScreenContent = ({ + entry, + namespaces, +}: ContentContainerScreenProps) => ( + +

+ +
+ +); diff --git a/packages/vitnode/src/tanstack/admin/content/route.tsx b/packages/vitnode/src/tanstack/admin/content/route.tsx index 78b3cd45c1..1860b0663e 100644 --- a/packages/vitnode/src/tanstack/admin/content/route.tsx +++ b/packages/vitnode/src/tanstack/admin/content/route.tsx @@ -12,6 +12,8 @@ import type { ContentRouteLabels } from "@/views/admin/views/content/content-lab import { resolveContentAdminRoute } from "@/content/index"; import { CONTENT_PERMISSIONS } from "@/content/index"; +import { contentContainerListQuery } from "@/views/admin/views/containers/container-query"; +import { contentContainerApiTarget } from "@/views/admin/views/containers/container-request"; import { contentLabelsFrom, contentRouteLabels, @@ -19,6 +21,7 @@ import { } from "@/views/admin/views/content/content-labels"; import type { AdminScreenContext } from "../screen"; +import type { AdminAccessState } from "../session-api"; import type { ContentListParams, UncheckedContentListSearch, @@ -26,6 +29,7 @@ import type { import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; +import { hasAdminPermission } from "../state"; import { contentListPageQuery } from "./query"; import { contentListRouteParams } from "./route-search"; @@ -78,6 +82,49 @@ export interface ContentAdminRouteData { title: string; } +const preloadListContainers = async ({ + adminAccess, + entry, + queryClient, + registry, +}: Pick & { + adminAccess: AdminAccessState; + entry: RegisteredFrontendContentType; + registry: ContentFrontendRegistry; +}): Promise => { + const { fields } = entry.definition; + const reads = entry.definition.admin.list.columns.flatMap(name => { + const descriptor = fields[name] as (typeof fields)[string] | undefined; + if (descriptor?.kind !== "container") return []; + + const collection = registry.containers.byId(descriptor.definition.id); + if ( + !collection || + !hasAdminPermission(adminAccess, { + module: collection.definition.admin.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: collection.pluginId, + }) + ) { + return []; + } + + return [ + queryClient + .query({ + ...contentContainerListQuery({ + definitionId: collection.definition.id, + target: contentContainerApiTarget(collection), + }), + staleTime: "static", + }) + .catch(() => undefined), + ]; + }); + + await Promise.all(reads); +}; + export const loadContentAdminRoute = async ({ adminAccess, locale, @@ -127,6 +174,9 @@ export const loadContentAdminRoute = async ({ staleTime: "static", }) : undefined, + listParams + ? preloadListContainers({ adminAccess, entry, queryClient, registry }) + : undefined, ]); const t = createTranslator({ diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts index 4a90625e8c..5db1877597 100644 --- a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts @@ -228,7 +228,7 @@ describe("the universal fetcher resolves a route from the plugin id", () => { path: "/", }); - expectTypeOf(list.status).toEqualTypeOf<200 | 400>(); + expectTypeOf(list.status).toEqualTypeOf<200 | 400 | 404>(); const detail = await fetcher({ plugin: "@acme/notes", diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 2f5abcfbea..d21faebe72 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -1,5 +1,6 @@ import type { LocaleRoutingConfig } from "@/lib/i18n/locale-routing"; +import { defineContentContainer } from "@/content/containers/define"; import { defineContentType } from "@/content/define"; import { field } from "@/content/fields"; import { createLocaleRouting } from "@/lib/i18n/locale-routing"; @@ -934,3 +935,42 @@ export const testEditorialTopicContentType = defineContentType({ update: { fields: ["title"] }, }, }); + +export const testBoardContainers = defineContentContainer({ + id: "test.boards", + fields: { + description: field.textarea({ maxLength: 500, nullable: true }), + color: field.text({ maxLength: 7, nullable: true }), + locked: field.boolean({ defaultValue: false }), + notes: field.textarea({ maxLength: 500, nullable: true }), + }, + hierarchy: { maxDepth: 3 }, + publicApi: { path: "boards", fields: ["description", "color"] }, +}); + +export const testSectionContainers = defineContentContainer({ + id: "test.sections", + publicApi: { path: "sections" }, +}); + +export const testThreadContentType = defineContentType({ + id: "test.thread", + tableName: "test_threads", + publication: true, + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + author: field.user(), + board: field.container({ definition: testBoardContainers, required: true }), + }, + publicApi: { + path: "threads", + fields: ["id", "title", "slug", "board", "publishedAt"], + filterableFields: ["board"], + }, + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["title", "board"], status: "published" }, + update: { fields: ["title"] }, + }, +}); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 7d0190abd8..79e8368eb2 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -1,5 +1,6 @@ import { FileTextIcon, + FolderTreeIcon, LayoutDashboardIcon, ServerIcon, ShieldUserIcon, @@ -16,6 +17,11 @@ import type { VitNodeConfig } from "@/vitnode.config"; import { hasStaffPermission } from "@/api/lib/staff-permission"; import { CONFIG_PLUGIN } from "@/config"; +import { + contentContainerAdminHref, + contentContainerNamespace, + contentContainerNouns, +} from "@/content/admin/containers"; import { contentEntityKey, type ContentLabelTranslator, @@ -48,6 +54,7 @@ export type AdminNavTranslator = ContentLabelTranslator; export type AdminNavTitle = | { contentTypeId: string; kind: "content"; pluginId: string } + | { definitionId: string; kind: "container"; pluginId: string } | { key: string; kind: "key"; @@ -81,6 +88,10 @@ export interface AdminNavGroupDeclaration { /** One un-translated title, resolved. */ const resolveTitle = (title: AdminNavTitle, t: AdminNavTranslator): string => { if (title.kind === "key") return t(title.key); + if (title.kind === "container") { + return contentContainerNouns({ id: title.definitionId }, title.pluginId, t) + .title; + } // The same resolution the screen's own heading uses, so the sidebar and the // page it opens cannot disagree about what the records are called. @@ -286,6 +297,26 @@ const contentNavItems = ( }, })); +const containerNavItems = ( + plugin: VitNodeConfig["plugins"][number], +): AdminNavItemDeclaration[] => + (plugin.containers ?? []) + .filter(({ definition }) => definition.admin.navigation.enabled) + .map(({ definition, icon }) => ({ + href: contentContainerAdminHref(definition), + icon: icon ?? , + permission: { + module: definition.admin.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: plugin.pluginId, + }, + title: { + definitionId: definition.id, + kind: "container" as const, + pluginId: plugin.pluginId, + }, + })); + const declaredNavItems = ( plugin: VitNodeConfig["plugins"][number], ): AdminNavItemDeclaration[] => @@ -339,7 +370,11 @@ export const adminNavDeclarations = ( // readily as a branch, and asking for `@vitnode/blog` instead would ship // every string that plugin has in order to render one heading. title: key(`${plugin.pluginId}.title`, `${plugin.pluginId}.title`), - items: [...contentNavItems(plugin), ...declaredNavItems(plugin)], + items: [ + ...contentNavItems(plugin), + ...containerNavItems(plugin), + ...declaredNavItems(plugin), + ], })), ]; @@ -354,10 +389,17 @@ export const adminNavDeclarations = ( * `.label` - and the branch holding both is exactly what a screen already loads * for that content type. */ -const titleNamespace = (title: AdminNavTitle): string => - title.kind === "key" - ? title.namespace - : `${title.pluginId}.content.${contentEntityKey(title.contentTypeId)}`; +const titleNamespace = (title: AdminNavTitle): string => { + if (title.kind === "key") return title.namespace; + if (title.kind === "container") { + return contentContainerNamespace( + { id: title.definitionId }, + title.pluginId, + ); + } + + return `${title.pluginId}.content.${contentEntityKey(title.contentTypeId)}`; +}; /** * Every message namespace a set of declarations needs, and not one more. diff --git a/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx b/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx new file mode 100644 index 0000000000..5698c1d8a5 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-dialogs.tsx @@ -0,0 +1,193 @@ +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; + +import type { ContentContainerFormProps } from "./container-form-content"; +import type { + AdminContentContainer, + ContentContainerMutationResult, +} from "./container-request"; + +import { contentContainerErrorKey } from "./container-query"; + +const ContentContainerFormContent = React.lazy(async () => + import("./container-form-content").then(module => ({ + default: module.ContentContainerFormContent, + })), +); + +export const ContentContainerSheet = ({ + formKey, + onOpenChange, + open, + ...form +}: Omit & { + container: AdminContentContainer | undefined; + formKey: number; + mode: "create" | "edit"; + onOpenChange: (open: boolean) => void; + open: boolean; +}) => { + const t = useTranslations("core.containers"); + const { container, mode, ...rest } = form; + const isEdit = mode === "edit"; + + return ( + + + + + {t(isEdit ? "edit.title" : "create.title", { + name: form.labels.singular, + })} + + + {isEdit && container + ? container.name + : t("create.desc", { name: form.labels.singular })} + + + + {isEdit && !container ? null : ( + + + + } + > + + + )} + + + ); +}; + +const DeleteBody = ({ + childCount, + container, + onDelete, + onOpenChange, + singular, +}: { + childCount: number; + container: AdminContentContainer; + onDelete: (id: number) => Promise>; + onOpenChange: (open: boolean) => void; + singular: string; +}) => { + const t = useTranslations("core.containers"); + const [isPending, startTransition] = React.useTransition(); + const [refusal, setRefusal] = React.useState(() => + childCount > 0 ? t("errors.hasChildren") : null, + ); + const refusalId = React.useId(); + + const onConfirm = () => { + startTransition(async () => { + const result = await onDelete(container.id); + + if ("error" in result) { + const message = t(contentContainerErrorKey(result.error)); + setRefusal(message); + toast.error(t("delete.failed", { name: container.name }), { + description: message, + }); + + return; + } + + toast.success(t("delete.success", { name: singular }), { + description: t("delete.successDesc", { name: container.name }), + }); + onOpenChange(false); + }); + }; + + return ( + <> + + + {t("delete.title", { name: singular })} + + + {t("delete.desc", { name: container.name })} + + + + {refusal ? ( + + ) : null} + + + {t("delete.cancel")} + + + + ); +}; + +export const ContentContainerDeleteDialog = ({ + container, + onOpenChange, + open, + ...props +}: Omit, "container"> & { + container: AdminContentContainer | undefined; + open: boolean; +}) => ( + + + {container ? ( + + ) : null} + + +); diff --git a/packages/vitnode/src/views/admin/views/containers/container-form-content.tsx b/packages/vitnode/src/views/admin/views/containers/container-form-content.tsx new file mode 100644 index 0000000000..2ed073e7cb --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-form-content.tsx @@ -0,0 +1,306 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { + AutoFormOnSubmit, + ItemAutoFormComponentProps, +} from "@/components/form/auto-form"; +import type { ContentContainerLabels } from "@/content/admin/containers"; +import type { + ContentFormFieldSpec, + ContentFormSpec, +} from "@/content/admin/spec"; +import type { ContentContainerTreeRules } from "@/content/containers/tree"; +import type { AnyContentContainerDefinition } from "@/content/containers/types"; +import type { ContentFieldDescriptor } from "@/content/types"; + +import { AutoForm, setFormFieldError } from "@/components/form/auto-form"; +import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { useDialog } from "@/components/ui/dialog"; +import { + buildFormSchemaFromSpec, + projectFormField, +} from "@/content/admin/spec"; +import { + CONTENT_CONTAINER_NAME_MAX_LENGTH, + CONTENT_CONTAINER_SLUG_MAX_LENGTH, +} from "@/content/containers/const"; +import { planContentContainerCreate } from "@/content/containers/tree"; +import { ContentField } from "@/views/admin/views/content/lib/field-component"; + +import type { + AdminContentContainer, + ContentContainerMutationResult, + ContentContainerWriteBody, +} from "./container-request"; + +import { contentContainerErrorKey } from "./container-query"; +import { + AutoFormContainerSelect, + containerSelectOptions, +} from "./container-select"; + +export interface ContentContainerSaveArgs { + body: ContentContainerWriteBody; + id?: number; + parentId?: null | number; +} + +export type ContentContainerSave = ( + args: ContentContainerSaveArgs, +) => Promise>; + +export interface ContentContainerFormProps { + container?: AdminContentContainer; + definition: AnyContentContainerDefinition; + labels: Pick; + onSave: ContentContainerSave; + onSaved?: () => void; + parentId?: null | number; + pluginId: string; + records: readonly AdminContentContainer[]; + rules: ContentContainerTreeRules; +} + +const SLUG_ERRORS: ReadonlySet = new Set(["slugEmpty", "slugTaken"]); + +const noOptions = async () => await Promise.resolve([]); + +const metadataSpec = ( + definition: AnyContentContainerDefinition, + labels: ContentContainerFormProps["labels"], + pluginId: string, +): ContentFormSpec => ({ + contentTypeId: definition.id, + defaultLocale: null, + fields: Object.entries(definition.fields).map(([name, descriptor]) => + projectFormField( + name, + descriptor as ContentFieldDescriptor, + labels.labelEnum, + fieldName => labels.labelField(fieldName), + ), + ), + permissionModule: definition.admin.permissionModule, + pluginId, + sections: [], + titleField: null, +}); + +const metadataForApi = ( + fields: readonly ContentFormFieldSpec[], + values: Record, +): Record => + Object.fromEntries( + fields.flatMap(field => { + const value = values[field.name]; + if (value === undefined) return []; + if ( + value === "" && + field.nullable && + (field.kind === "text" || field.kind === "textarea") + ) { + return [[field.name, null]]; + } + + return [[field.name, value]]; + }), + ); + +export const ContentContainerFormContent = ({ + container, + definition, + labels, + onSave, + onSaved, + parentId: initialParentId = null, + pluginId, + records, + rules, +}: ContentContainerFormProps) => { + const t = useTranslations("core.containers"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + const isEdit = container !== undefined; + + const spec = metadataSpec(definition, labels, pluginId); + const metadataShape = buildFormSchemaFromSpec( + spec, + container?.metadata, + ).shape; + + const parents = isEdit + ? [] + : records.filter( + record => + planContentContainerCreate( + records, + { parentId: record.id, position: undefined }, + rules, + ).ok, + ); + const showParent = !isEdit && rules.hierarchy; + const parentIds = new Set(parents.map(record => record.id)); + + const formSchema = z.object({ + name: z + .string() + .trim() + .min(1) + .max(CONTENT_CONTAINER_NAME_MAX_LENGTH) + .default(container?.name ?? ""), + slug: z + .string() + .trim() + .max(CONTENT_CONTAINER_SLUG_MAX_LENGTH) + .default(container?.slug ?? ""), + ...(showParent + ? { + parentId: z + .number() + .int() + .positive() + .nullable() + .default( + initialParentId !== null && parentIds.has(initialParentId) + ? initialParentId + : null, + ), + } + : {}), + ...metadataShape, + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const { name, slug, ...rest } = values as Record & { + name: string; + slug: string; + }; + const parentId = showParent + ? ((rest.parentId as null | number | undefined) ?? null) + : null; + const body: ContentContainerWriteBody = { + metadata: metadataForApi(spec.fields, rest), + name, + ...(slug === "" ? {} : { slug }), + }; + + const result = await onSave( + isEdit ? { body, id: container.id } : { body, parentId }, + ); + + if ("error" in result) { + const message = t(contentContainerErrorKey(result.error)); + if (result.error.code && SLUG_ERRORS.has(result.error.code)) { + setFormFieldError(form, "slug", message); + + return; + } + + toast.error(tError("title"), { description: message }); + + return; + } + + toast.success(t(isEdit ? "edit.success" : "create.success"), { + description: t(isEdit ? "edit.successDesc" : "create.successDesc", { + name, + }), + }); + setIsDirty?.(false); + setOpen?.(false); + onSaved?.(); + }; + + const metadataFields = spec.fields.map(fieldSpec => ({ + component: (props: ItemAutoFormComponentProps) => ( + + ), + id: fieldSpec.name, + })); + + return ( + ( + + ), + id: "name", + }, + { + component: props => ( + + ), + id: "slug", + }, + ...(showParent + ? [ + { + component: (props: ItemAutoFormComponentProps) => ( + ({ + ...option, + depth: option.depth + 1, + })), + ]} + /> + ), + id: "parentId", + }, + ] + : []), + ...metadataFields, + ]} + formSchema={formSchema} + layout={rendered => ( + <> +
+ {rendered.name} + {rendered.slug} + {showParent ? rendered.parentId : null} + {spec.fields.length > 0 ? ( +
+ {spec.fields.map(fieldSpec => ( +
+ {rendered[fieldSpec.name]} +
+ ))} +
+ ) : null} +
+ + + )} + onSubmit={onSubmit} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/containers/container-manager.tsx b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx new file mode 100644 index 0000000000..df5d55f676 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx @@ -0,0 +1,702 @@ +import type { + Announcements, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, + KeyboardCoordinateGetter, + UniqueIdentifier, +} from "@dnd-kit/core"; + +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + MeasuringStrategy, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { cn } from "cn"; +import { FolderTreeIcon, PlusIcon, RotateCwIcon } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { ContentContainerLabels } from "@/content/admin/containers"; +import type { AnyContentContainerDefinition } from "@/content/containers/types"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { PageTitle } from "@/components/ui/page-title"; +import { Skeleton } from "@/components/ui/skeleton"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { planContentContainerCreate } from "@/content/containers/tree"; +import { DragLift } from "@/lib/dnd/drag-lift"; +import { + DRAG_OVERLAY_LIFT_CLASS, + LIFTED_DROP_ANIMATION, +} from "@/lib/dnd/drag-motion"; +import { FinePointerSensor } from "@/lib/dnd/sensors"; +import { isAdminRequestError } from "@/views/admin/admin-request"; + +import type { ContentContainerSave } from "./container-form-content"; +import type { + AdminContentContainer, + ContentContainerMutationResult, +} from "./container-request"; +import type { ContainerDropProjection } from "./container-tree"; + +import { + ContentContainerDeleteDialog, + ContentContainerSheet, +} from "./container-dialogs"; +import { contentContainerErrorKey } from "./container-query"; +import { + ContainerDragChip, + type ContainerRowActions, + type ContainerRowMoves, + SortableContainerRow, +} from "./container-row"; +import { + applyContainerPlacements, + canRearrangeContainers, + checkContainerMove, + CONTAINER_INDENTATION_PX, + containerChildCounts, + containerDescendants, + containerParentTargets, + containerSiblings, + containerTreeRules, + projectContainerDrop, + rootContainerMove, + shiftContainerMove, + visibleContainers, +} from "./container-tree"; + +export interface ContentContainerManagerProps { + definition: AnyContentContainerDefinition; + error?: unknown; + icon?: React.ReactNode; + isPending: boolean; + labels: ContentContainerLabels; + onDelete: (id: number) => Promise>; + onMove: (move: { + id: number; + parentId: null | number; + position: number; + }) => Promise>; + onRetry: () => void; + onSave: ContentContainerSave; + pluginId: string; + records: AdminContentContainer[] | undefined; +} + +const containerKeyboardCoordinates: KeyboardCoordinateGetter = ( + event, + args, +) => { + if (event.code === "ArrowLeft" || event.code === "ArrowRight") { + event.preventDefault(); + + return { + x: + args.currentCoordinates.x + + (event.code === "ArrowRight" + ? CONTAINER_INDENTATION_PX + : -CONTAINER_INDENTATION_PX), + y: args.currentCoordinates.y, + }; + } + + return sortableKeyboardCoordinates(event, args); +}; + +const subscribeToNothing = () => () => undefined; +const getAnnouncementContainer = () => document.body; +const getNoAnnouncementContainer = () => null; + +const ContainerListSkeleton = () => ( +
+ {["w-48", "w-40", "w-56", "w-36"].map((width, index) => ( +
+ {index % 2 === 1 ? : null} + + +
+ ))} +
+); + +const ContainerListError = ({ + error, + onRetry, + plural, +}: { + error: unknown; + onRetry: () => void; + plural: string; +}) => { + const t = useTranslations("core.containers.error"); + const forbidden = isAdminRequestError(error) && error.status === 403; + + return ( + + + + + + {t("title", { name: plural })} + + {t(forbidden ? "forbidden" : "desc")} + + + {forbidden ? null : ( + + + + )} + + ); +}; + +const useOverlay = () => { + const [state, setState] = React.useState<{ + id: null | number; + isOpen: boolean; + parentId: null | number; + session: number; + }>({ id: null, isOpen: false, parentId: null, session: 0 }); + + return { + ...state, + setOpen: (isOpen: boolean) => { + setState(current => ({ ...current, isOpen })); + }, + show: (next: { id?: number; parentId?: null | number }) => { + setState(current => ({ + id: next.id ?? null, + isOpen: true, + parentId: next.parentId ?? null, + session: current.session + 1, + })); + }, + }; +}; + +export const ContentContainerManager = ({ + definition, + error, + icon, + isPending, + labels, + onDelete, + onMove, + onRetry, + onSave, + pluginId, + records, +}: ContentContainerManagerProps) => { + const t = useTranslations("core.containers"); + const reduceMotion = useReducedMotion(); + const rules = containerTreeRules(definition); + const permissionOf = (permission: string) => ({ + module: definition.admin.permissionModule, + permission, + plugin: pluginId, + }); + const canCreate = useAdminStaffPermission( + permissionOf(CONTENT_PERMISSIONS.create), + ); + const canEdit = useAdminStaffPermission( + permissionOf(CONTENT_PERMISSIONS.edit), + ); + const canDelete = useAdminStaffPermission( + permissionOf(CONTENT_PERMISSIONS.delete), + ); + const canMove = canEdit && canRearrangeContainers(rules); + const permissions = { canCreate, canDelete, canEdit, canMove }; + + const [ordered, setOrdered] = React.useState(records ?? []); + const [synced, setSynced] = React.useState(records); + if (synced !== records) { + setSynced(records); + setOrdered(records ?? []); + } + + const [collapsed, setCollapsed] = React.useState>( + () => new Set(), + ); + const [activeId, setActiveId] = React.useState(null); + const [overId, setOverId] = React.useState(null); + const [offsetLeft, setOffsetLeft] = React.useState(0); + const [isMoving, startMoving] = React.useTransition(); + const sheet = useOverlay(); + const [sheetMode, setSheetMode] = React.useState<"create" | "edit">("create"); + const deleting = useOverlay(); + const announcementContainer = React.useSyncExternalStore( + subscribeToNothing, + getAnnouncementContainer, + getNoAnnouncementContainer, + ); + + const childCounts = React.useMemo( + () => containerChildCounts(ordered), + [ordered], + ); + const shown = React.useMemo( + () => visibleContainers(ordered, collapsed), + [collapsed, ordered], + ); + const activeDescendants = React.useMemo( + () => (activeId === null ? [] : containerDescendants(ordered, activeId)), + [activeId, ordered], + ); + const visible = React.useMemo(() => { + if (activeDescendants.length === 0) return shown; + const travelling = new Set(activeDescendants.map(record => record.id)); + + return shown.filter(record => !travelling.has(record.id)); + }, [activeDescendants, shown]); + const parentIds = ordered + .filter(record => (childCounts.get(record.id) ?? 0) > 0) + .map(record => record.id); + + const activeRecord = + activeId === null + ? undefined + : ordered.find(record => record.id === activeId); + const nameOf = (id: UniqueIdentifier) => + ordered.find(record => record.id === Number(id))?.name ?? String(id); + + const projectionFor = ( + target: null | number, + ): ContainerDropProjection | null => + activeId !== null && target !== null + ? projectContainerDrop({ + activeId, + offsetLeft, + overId: target, + records: ordered, + rules, + visible, + }) + : null; + const projection = projectionFor(overId); + + const sensors = useSensors( + useSensor(FinePointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: containerKeyboardCoordinates, + }), + ); + + const expand = (id: number) => { + setCollapsed(current => { + if (!current.has(id)) return current; + const next = new Set(current); + next.delete(id); + + return next; + }); + }; + + const commitMove = (move: { + id: number; + parentId: null | number; + position: number; + }) => { + const name = nameOf(move.id); + const check = checkContainerMove(ordered, move, rules); + if (check.kind === "unchanged") return; + if (check.kind === "invalid") { + toast.error(t("list.moveFailed", { name }), { + description: t(`errors.${check.code}`), + }); + + return; + } + + const previous = ordered; + setOrdered(applyContainerPlacements(ordered, check.placements, rules)); + if (move.parentId !== null) expand(move.parentId); + + startMoving(async () => { + const result = await onMove(move); + + if ("error" in result) { + setOrdered(previous); + toast.error(t("list.moveFailed", { name }), { + description: t(contentContainerErrorKey(result.error)), + }); + + return; + } + + toast.success(t("list.moveSuccess"), { + description: t("list.moveSuccessDesc", { name }), + }); + }); + }; + + const describeDrop = ( + kind: "dropped" | "over", + id: UniqueIdentifier, + target: null | number, + ): string => { + const name = nameOf(id); + const drop = projectionFor(target); + if (!drop || drop.check.kind === "unchanged") { + return t("dnd.unchanged", { name }); + } + if (drop.check.kind === "invalid") { + return t(`dnd.${kind}Invalid`, { + name, + reason: t(`errors.${drop.check.code}`), + }); + } + + const values = { + name, + position: drop.position + 1, + sorted: String(rules.mode === "alphabetical"), + }; + + return drop.parentId === null + ? t(`dnd.${kind}Root`, values) + : t(`dnd.${kind}Inside`, { ...values, parent: nameOf(drop.parentId) }); + }; + + const announcements: Announcements = { + onDragCancel: ({ active }) => + t("dnd.cancelled", { name: nameOf(active.id) }), + onDragEnd: ({ active, over }) => + over + ? describeDrop("dropped", active.id, Number(over.id)) + : t("dnd.cancelled", { name: nameOf(active.id) }), + onDragOver: ({ active, over }) => + over ? describeDrop("over", active.id, Number(over.id)) : undefined, + onDragStart: ({ active }) => t("dnd.started", { name: nameOf(active.id) }), + }; + + const reset = () => { + setActiveId(null); + setOverId(null); + setOffsetLeft(0); + }; + + const onDragStart = ({ active }: DragStartEvent) => { + setActiveId(Number(active.id)); + setOverId(Number(active.id)); + setOffsetLeft(0); + }; + + const onDragMove = ({ delta }: DragMoveEvent) => { + setOffsetLeft(delta.x); + }; + + const onDragOver = ({ over }: DragOverEvent) => { + setOverId(over ? Number(over.id) : null); + }; + + const onDragEnd = ({ over }: DragEndEvent) => { + const dragged = activeId; + const drop = over ? projectionFor(Number(over.id)) : null; + reset(); + + if (dragged === null || !drop) return; + if (drop.check.kind === "invalid") { + toast.error(t("list.moveFailed", { name: nameOf(dragged) }), { + description: t(`errors.${drop.check.code}`), + }); + + return; + } + + commitMove({ + id: dragged, + parentId: drop.parentId, + position: drop.position, + }); + }; + + const actions: ContainerRowActions = { + onAddChild: parentId => { + expand(parentId); + setSheetMode("create"); + sheet.show({ parentId }); + }, + onDelete: id => { + deleting.show({ id }); + }, + onEdit: id => { + setSheetMode("edit"); + sheet.show({ id }); + }, + onMoveInside: (id, parentId) => { + commitMove({ id, parentId, position: childCounts.get(parentId) ?? 0 }); + }, + onMoveToRoot: id => { + const move = rootContainerMove(ordered, id); + if (move) commitMove(move); + }, + onShift: (id, step) => { + const move = shiftContainerMove(ordered, id, step); + if (move) commitMove(move); + }, + onToggle: id => { + setCollapsed(current => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + + return next; + }); + }, + }; + + const movesOf = (record: AdminContentContainer): ContainerRowMoves => { + const canAddChild = + canCreate && + planContentContainerCreate( + ordered, + { parentId: record.id, position: undefined }, + rules, + ).ok; + + if (!canMove || isMoving) { + return { + canAddChild, + canMoveDown: false, + canMoveToRoot: false, + canMoveUp: false, + parents: [], + }; + } + + const siblings = containerSiblings(ordered, record.parentId); + const at = siblings.findIndex(entry => entry.id === record.id); + const manual = rules.mode === "manual"; + + return { + canAddChild, + canMoveDown: manual && at < siblings.length - 1, + canMoveToRoot: rules.hierarchy && record.parentId !== null, + canMoveUp: manual && at > 0, + parents: containerParentTargets(ordered, record.id, rules), + }; + }; + + const openCreate = () => { + setSheetMode("create"); + sheet.show({}); + }; + + const createButton = canCreate ? ( + + ) : null; + + const renderBody = () => { + if (records === undefined && isPending) return ; + + if (records === undefined) { + return ( + + ); + } + + if (ordered.length === 0) { + return ( + + + + + + + {t("empty.title", { name: labels.plural })} + + + {t("empty.desc")} + + + {createButton ? {createButton} : null} + + ); + } + + return ( +
+
+

+ {[ + t("list.summary", { count: ordered.length }), + rules.hierarchy + ? t("list.depth", { max: rules.maxDepth }) + : t("list.flat"), + ...(rules.mode === "alphabetical" ? [t("list.sorted")] : []), + ].join(" · ")} +

+ {parentIds.length > 0 ? ( + + ) : null} +
+ + + record.id)} + strategy={verticalListSortingStrategy} + > +
    + {visible.map(record => { + const isActive = record.id === activeId; + + return ( + + ); + })} +
+
+ + + {activeRecord ? ( + +
+ +
+
+ ) : null} +
+
+
+ ); + }; + + const sheetContainer = + sheet.id === null + ? undefined + : ordered.find(record => record.id === sheet.id); + const deletingContainer = + deleting.id === null + ? undefined + : ordered.find(record => record.id === deleting.id); + + return ( + <> + + {records === undefined || ordered.length === 0 ? null : createButton} + + + {renderBody()} + + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/containers/container-query.ts b/packages/vitnode/src/views/admin/views/containers/container-query.ts new file mode 100644 index 0000000000..fa50f7c862 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-query.ts @@ -0,0 +1,61 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { queryOptions } from "@tanstack/react-query"; + +import type { ContentContainerErrorCode } from "@/content/containers/types"; + +import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { isAdminRequestError } from "@/views/admin/admin-request"; +import { adminQueryRoot } from "@/views/admin/table/query"; + +import type { ContentContainerApiTarget } from "./container-request"; + +import { fetchContentContainers } from "./container-request"; + +export const contentContainerQueryRoot = (definitionId: string) => + [...adminQueryRoot("containers"), definitionId] as const; + +export const contentContainerListQueryKey = (definitionId: string) => + [...contentContainerQueryRoot(definitionId), "list"] as const; + +export const contentContainerListQuery = ({ + definitionId, + target, +}: { + definitionId: string; + target: ContentContainerApiTarget; +}) => + queryOptions({ + queryFn: async ({ signal }) => + await fetchContentContainers(target, { signal }), + queryKey: contentContainerListQueryKey(definitionId), + retry: (failureCount, error) => + !(isAdminRequestError(error) && error.status < 500) && failureCount < 2, + staleTime: RECORD_STALE_TIME, + }); + +export const invalidateContentContainers = async ( + queryClient: QueryClient, + definitionId: string, +): Promise => { + await queryClient.invalidateQueries({ + queryKey: contentContainerQueryRoot(definitionId), + }); +}; + +export type ContentContainerErrorKey = + `errors.${"forbidden" | "unknown" | ContentContainerErrorCode}`; + +export const contentContainerErrorKey = ({ + code, + status, +}: { + code?: ContentContainerErrorCode; + status: number; +}): ContentContainerErrorKey => { + if (code !== undefined) return `errors.${code}`; + if (status === 403) return "errors.forbidden"; + if (status === 404) return "errors.notFound"; + + return "errors.unknown"; +}; diff --git a/packages/vitnode/src/views/admin/views/containers/container-request.ts b/packages/vitnode/src/views/admin/views/containers/container-request.ts new file mode 100644 index 0000000000..d849ae4b8c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-request.ts @@ -0,0 +1,164 @@ +import { z } from "zod"; + +import type { ContentContainerErrorCode } from "@/content/containers/types"; + +import { CONTENT_CONTAINER_ERROR_CODES } from "@/content/containers/const"; +import { rawFetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; + +export interface ContentContainerApiTarget { + permissionModule: string; + pluginId: string; +} + +export const contentContainerApiTarget = ({ + definition, + pluginId, +}: { + definition: { admin: { permissionModule: string } }; + pluginId: string; +}): ContentContainerApiTarget => ({ + permissionModule: definition.admin.permissionModule, + pluginId, +}); + +const adminContentContainerSchema = z.object({ + createdAt: z.string(), + depth: z.number().int().positive(), + id: z.number().int().positive(), + metadata: z.record(z.string(), z.unknown()), + name: z.string(), + parentId: z.number().int().positive().nullable(), + position: z.number().int().min(0), + slug: z.string(), + updatedAt: z.string(), +}); + +export type AdminContentContainer = z.infer; + +const listSchema = z.object({ items: z.array(adminContentContainerSchema) }); + +const errorSchema = z.object({ code: z.enum(CONTENT_CONTAINER_ERROR_CODES) }); + +const describe = (target: ContentContainerApiTarget) => + `the ${target.permissionModule} containers of ${target.pluginId}`; + +const requestArgs = (target: ContentContainerApiTarget) => ({ + module: `content/${target.permissionModule}`, + pluginId: target.pluginId, + prefixPath: "/admin", +}); + +export const fetchContentContainers = async ( + target: ContentContainerApiTarget, + { signal }: { signal?: AbortSignal } = {}, +): Promise => { + const response = await rawFetcher({ + ...requestArgs(target), + method: "get", + options: { signal }, + path: "/", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, describe(target)); + } + + const parsed = listSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new AdminRequestError( + response.status, + describe(target), + "the API returned a response this collection does not describe", + ); + } + + return parsed.data.items; +}; + +export type ContentContainerMutationResult = + | { data: T } + | { error: { code?: ContentContainerErrorCode; status: number } }; + +const readError = async ( + response: Response, +): Promise => { + try { + const parsed = errorSchema.safeParse(await response.json()); + + return parsed.success ? parsed.data.code : undefined; + } catch { + return undefined; + } +}; + +const send = async ( + target: ContentContainerApiTarget, + request: { + body?: unknown; + method: "delete" | "patch" | "post"; + params?: Record; + path: string; + }, +): Promise> => { + let response: Response; + try { + response = await rawFetcher({ ...requestArgs(target), ...request }); + } catch { + return { error: { status: 500 } }; + } + + if (!response.ok) { + return { + error: { code: await readError(response), status: response.status }, + }; + } + + return { data: null }; +}; + +export interface ContentContainerWriteBody { + metadata?: Record; + name?: string; + slug?: string; +} + +export const createContentContainer = async ( + target: ContentContainerApiTarget, + body: ContentContainerWriteBody & { name: string; parentId: null | number }, +): Promise> => + await send(target, { body, method: "post", path: "/" }); + +export const updateContentContainer = async ( + target: ContentContainerApiTarget, + id: number, + body: ContentContainerWriteBody, +): Promise> => + await send(target, { + body, + method: "patch", + params: { id }, + path: "/{id}", + }); + +export const moveContentContainer = async ( + target: ContentContainerApiTarget, + id: number, + body: { parentId: null | number; position: number }, +): Promise> => + await send(target, { + body, + method: "post", + params: { id }, + path: "/{id}/move", + }); + +export const deleteContentContainer = async ( + target: ContentContainerApiTarget, + id: number, +): Promise> => + await send(target, { + method: "delete", + params: { id }, + path: "/{id}", + }); diff --git a/packages/vitnode/src/views/admin/views/containers/container-row.tsx b/packages/vitnode/src/views/admin/views/containers/container-row.tsx new file mode 100644 index 0000000000..7c03c67276 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-row.tsx @@ -0,0 +1,449 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "cn"; +import { + ArrowDownIcon, + ArrowUpIcon, + ChevronRightIcon, + CornerDownRightIcon, + CornerLeftUpIcon, + FolderIcon, + GripVerticalIcon, + MoreHorizontalIcon, + PencilIcon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import type { AdminContentContainer } from "./container-request"; + +import { containerGuideLevels } from "./container-select"; + +export interface ContainerRowPermissions { + canCreate: boolean; + canDelete: boolean; + canEdit: boolean; + canMove: boolean; +} + +export interface ContainerRowActions { + onAddChild: (parentId: number) => void; + onDelete: (id: number) => void; + onEdit: (id: number) => void; + onMoveInside: (id: number, parentId: number) => void; + onMoveToRoot: (id: number) => void; + onShift: (id: number, step: -1 | 1) => void; + onToggle: (id: number) => void; +} + +export interface ContainerRowMoves { + canAddChild: boolean; + canMoveDown: boolean; + canMoveToRoot: boolean; + canMoveUp: boolean; + parents: readonly AdminContentContainer[]; +} + +const IndentGuides = ({ depth }: { depth: number }) => + containerGuideLevels(depth).map(level => ( + + )); + +const ContainerGlyph = ({ icon }: { icon?: React.ReactNode }) => ( + + {icon ?? } + +); + +const ContainerRowMenu = ({ + actions, + container, + moves, + permissions, +}: { + actions: ContainerRowActions; + container: AdminContentContainer; + moves: ContainerRowMoves; + permissions: ContainerRowPermissions; +}) => { + const t = useTranslations("core.containers.list"); + const { id, name } = container; + const showMoves = + permissions.canMove && + (moves.canMoveUp || + moves.canMoveDown || + moves.canMoveToRoot || + moves.parents.length > 0); + + return ( + + + } + > + + + + + {permissions.canEdit ? ( + { + actions.onEdit(id); + }} + > + + {t("edit")} + + ) : null} + {moves.canAddChild ? ( + { + actions.onAddChild(id); + }} + > + + {t("addChild")} + + ) : null} + + {showMoves ? ( + <> + {permissions.canEdit || moves.canAddChild ? ( + + ) : null} + {moves.canMoveUp || moves.canMoveDown ? ( + <> + { + actions.onShift(id, -1); + }} + > + + {t("moveUp")} + + { + actions.onShift(id, 1); + }} + > + + {t("moveDown")} + + + ) : null} + {moves.canMoveToRoot ? ( + { + actions.onMoveToRoot(id); + }} + > + + {t("moveToTop")} + + ) : null} + {moves.parents.length > 0 ? ( + + + + {t("moveInside")} + + + {moves.parents.map(parent => ( + { + actions.onMoveInside(id, parent.id); + }} + > + + {parent.name} + + ))} + + + ) : null} + + ) : null} + + {permissions.canDelete ? ( + <> + {permissions.canEdit || moves.canAddChild || showMoves ? ( + + ) : null} + { + actions.onDelete(id); + }} + variant="destructive" + > + + {t("delete")} + + + ) : null} + + + ); +}; + +const ContainerRowExpander = ({ + childCount, + isCollapsed, + name, + onToggle, +}: { + childCount: number; + isCollapsed: boolean; + name: string; + onToggle: () => void; +}) => { + const t = useTranslations("core.containers.list"); + + if (childCount === 0) { + return ; + } + + return ( + + ); +}; + +const ContainerRowLabel = ({ + childCount, + container, + icon, + isCollapsed, +}: { + childCount: number; + container: AdminContentContainer; + icon?: React.ReactNode; + isCollapsed: boolean; +}) => ( + <> + + {container.name} + {isCollapsed && childCount > 0 ? ( + + +{childCount} + + ) : null} + + {container.slug} + + +); + +export const SortableContainerRow = ({ + actions, + childCount, + container, + depth, + disabled, + icon, + isCollapsed, + isInvalidDrop, + moves, + permissions, +}: { + actions: ContainerRowActions; + childCount: number; + container: AdminContentContainer; + depth: number; + disabled: boolean; + icon?: React.ReactNode; + isCollapsed: boolean; + isInvalidDrop: boolean; + moves: ContainerRowMoves; + permissions: ContainerRowPermissions; +}) => { + const t = useTranslations("core.containers.list"); + const { + attributes, + isDragging, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + } = useSortable({ + disabled: !permissions.canMove || disabled, + id: container.id, + }); + const showMenu = + permissions.canEdit || + permissions.canDelete || + moves.canAddChild || + permissions.canMove; + + return ( +
  • + + +
    + {permissions.canMove ? ( + + ) : null} + + { + actions.onToggle(container.id); + }} + /> + + {permissions.canEdit ? ( + + ) : ( + + + + )} + + {showMenu ? ( + + ) : null} +
    +
  • + ); +}; + +export const ContainerDragChip = ({ + container, + depth, + descendants, + icon, + isInvalidDrop, +}: { + container: AdminContentContainer; + depth: number; + descendants: number; + icon?: React.ReactNode; + isInvalidDrop: boolean; +}) => ( +
    + {containerGuideLevels(depth).map(level => ( + + ))} +
    + + + + + + {container.name} + {descendants > 0 ? ( + + +{descendants} + + ) : null} + +
    +
    +); diff --git a/packages/vitnode/src/views/admin/views/containers/container-select.tsx b/packages/vitnode/src/views/admin/views/containers/container-select.tsx new file mode 100644 index 0000000000..a8e9cadfcd --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-select.tsx @@ -0,0 +1,113 @@ +import { useTranslations } from "use-intl"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; + +import { AutoFormDesc } from "@/components/form/common/desc"; +import { AutoFormLabel } from "@/components/form/common/label"; +import { FormControl, FormMessage } from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import type { AdminContentContainer } from "./container-request"; + +export const containerGuideLevels = (depth: number): number[] => + Array.from({ length: Math.max(0, depth - 1) }, (_, at) => at + 1); + +const NONE = "__none__"; + +export interface ContainerSelectOption { + depth: number; + id: null | number; + label: string; +} + +export const containerSelectOptions = ( + records: readonly Pick[], +): ContainerSelectOption[] => + records.map(record => ({ + depth: record.depth, + id: record.id, + label: record.name, + })); + +const keyOf = (id: null | number) => (id === null ? NONE : String(id)); + +export const AutoFormContainerSelect = ({ + description, + disabled, + field, + label, + labelRight, + options, + otherProps: { isOptional }, + placeholder, +}: Pick< + ItemAutoFormComponentProps, + "description" | "field" | "label" | "labelRight" | "otherProps" +> & { + disabled?: boolean; + options: readonly ContainerSelectOption[]; + placeholder?: string; +}) => { + const t = useTranslations("core.global"); + const items = options.map(option => ({ + label: option.label, + value: keyOf(option.id), + })); + const current = field.value as null | number | undefined; + const value = + current === null || current === undefined + ? options.some(option => option.id === null) + ? NONE + : null + : String(current); + + return ( + <> + {!!label && ( + + {label} + + )} + + + + + + {!!description && {description}} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/containers/container-tree.ts b/packages/vitnode/src/views/admin/views/containers/container-tree.ts new file mode 100644 index 0000000000..affb228e29 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/containers/container-tree.ts @@ -0,0 +1,252 @@ +import { arrayMove } from "@dnd-kit/sortable"; + +import type { + ContentContainerPlacement, + ContentContainerTreeRules, +} from "@/content/containers/tree"; +import type { + AnyContentContainerDefinition, + ContentContainerErrorCode, +} from "@/content/containers/types"; + +import { + contentContainerAncestors, + orderContentContainers, + planContentContainerMove, +} from "@/content/containers/tree"; + +import type { AdminContentContainer } from "./container-request"; + +export const CONTAINER_INDENTATION_PX = 24; + +export const containerTreeRules = ( + definition: Pick, +): ContentContainerTreeRules => ({ + hierarchy: definition.hierarchy.enabled, + maxDepth: definition.hierarchy.maxDepth, + mode: definition.ordering.mode, +}); + +export const canRearrangeContainers = (rules: ContentContainerTreeRules) => + rules.mode === "manual" || rules.hierarchy; + +export const containerChildCounts = ( + records: readonly AdminContentContainer[], +): Map => { + const counts = new Map(); + for (const record of records) { + if (record.parentId === null) continue; + counts.set(record.parentId, (counts.get(record.parentId) ?? 0) + 1); + } + + return counts; +}; + +export const containerDescendants = ( + records: readonly AdminContentContainer[], + id: number, +): AdminContentContainer[] => { + const at = records.findIndex(record => record.id === id); + if (at === -1) return []; + + const { depth } = records[at]; + const descendants: AdminContentContainer[] = []; + for (const record of records.slice(at + 1)) { + if (record.depth <= depth) break; + descendants.push(record); + } + + return descendants; +}; + +export const visibleContainers = ( + records: readonly AdminContentContainer[], + collapsed: ReadonlySet, +): AdminContentContainer[] => { + const visible: AdminContentContainer[] = []; + let hiddenBelow: null | number = null; + + for (const record of records) { + if (hiddenBelow !== null && record.depth > hiddenBelow) continue; + hiddenBelow = collapsed.has(record.id) ? record.depth : null; + visible.push(record); + } + + return visible; +}; + +export type ContainerMoveCheck = + | { code: ContentContainerErrorCode; kind: "invalid" } + | { kind: "move"; placements: ContentContainerPlacement[] } + | { kind: "unchanged" }; + +export const checkContainerMove = ( + records: readonly AdminContentContainer[], + move: { id: number; parentId: null | number; position: number }, + rules: ContentContainerTreeRules, +): ContainerMoveCheck => { + const current = records.find(record => record.id === move.id); + if (rules.mode === "alphabetical" && current?.parentId === move.parentId) { + return { kind: "unchanged" }; + } + + const plan = planContentContainerMove(records, move, rules); + if (!plan.ok) return { code: plan.code, kind: "invalid" }; + + return plan.placements.length === 0 + ? { kind: "unchanged" } + : { kind: "move", placements: plan.placements }; +}; + +export const applyContainerPlacements = ( + records: readonly AdminContentContainer[], + placements: readonly ContentContainerPlacement[], + rules: ContentContainerTreeRules, +): AdminContentContainer[] => { + const byId = new Map(placements.map(placement => [placement.id, placement])); + + return orderContentContainers( + records.map(record => { + const placement = byId.get(record.id); + + return placement + ? { + ...record, + parentId: placement.parentId, + position: placement.position, + } + : record; + }), + rules.mode, + ); +}; + +export interface ContainerDropProjection { + check: ContainerMoveCheck; + depth: number; + parentId: null | number; + position: number; +} + +const parentAt = ( + entries: readonly AdminContentContainer[], + index: number, + depth: number, +): null | number => { + if (depth <= 1) return null; + + for (let at = index - 1; at >= 0; at -= 1) { + if (entries[at].depth === depth - 1) return entries[at].id; + if (entries[at].depth < depth - 1) break; + } + + return null; +}; + +export const projectContainerDrop = ({ + activeId, + offsetLeft, + overId, + records, + rules, + visible, +}: { + activeId: number; + offsetLeft: number; + overId: number; + records: readonly AdminContentContainer[]; + rules: ContentContainerTreeRules; + visible: readonly AdminContentContainer[]; +}): ContainerDropProjection | null => { + const from = visible.findIndex(record => record.id === activeId); + const to = visible.findIndex(record => record.id === overId); + if (from === -1 || to === -1) return null; + + const moved = arrayMove([...visible], from, to); + const active = visible[from]; + const previous = moved[to - 1] as AdminContentContainer | undefined; + const next = moved[to + 1] as AdminContentContainer | undefined; + + const projected = + active.depth + Math.round(offsetLeft / CONTAINER_INDENTATION_PX); + const maxDepth = rules.hierarchy ? (previous ? previous.depth + 1 : 1) : 1; + const minDepth = rules.hierarchy && next ? next.depth : 1; + const depth = Math.max(1, Math.min(Math.max(projected, minDepth), maxDepth)); + const parentId = parentAt(moved, to, depth); + const position = moved + .slice(0, to) + .filter( + record => record.id !== activeId && record.parentId === parentId, + ).length; + + return { + check: checkContainerMove( + records, + { id: activeId, parentId, position }, + rules, + ), + depth, + parentId, + position, + }; +}; + +export const containerSiblings = ( + records: readonly AdminContentContainer[], + parentId: null | number, +): AdminContentContainer[] => + records.filter(record => record.parentId === parentId); + +export const shiftContainerMove = ( + records: readonly AdminContentContainer[], + id: number, + step: -1 | 1, +): null | { id: number; parentId: null | number; position: number } => { + const record = records.find(entry => entry.id === id); + if (!record) return null; + + const siblings = containerSiblings(records, record.parentId); + const at = siblings.findIndex(entry => entry.id === id); + const position = at + step; + if (at === -1 || position < 0 || position >= siblings.length) return null; + + return { id, parentId: record.parentId, position }; +}; + +export const rootContainerMove = ( + records: readonly AdminContentContainer[], + id: number, +): null | { id: number; parentId: null; position: number } => { + const record = records.find(entry => entry.id === id); + if (record?.parentId === null || record === undefined) return null; + + const root = contentContainerAncestors(records, id)[0]; + const roots = containerSiblings(records, null); + + return { + id, + parentId: null, + position: root ? roots.findIndex(entry => entry.id === root.id) + 1 : 0, + }; +}; + +export const containerParentTargets = ( + records: readonly AdminContentContainer[], + id: number, + rules: ContentContainerTreeRules, +): AdminContentContainer[] => { + if (!rules.hierarchy) return []; + + const record = records.find(entry => entry.id === id); + const counts = containerChildCounts(records); + + return records.filter( + target => + target.id !== record?.parentId && + checkContainerMove( + records, + { id, parentId: target.id, position: counts.get(target.id) ?? 0 }, + rules, + ).kind === "move", + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx index a83c65e213..deceaa0200 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -22,6 +22,7 @@ const TEXTAREA_PREVIEW_LINES = 8; const TOKEN_KINDS = new Set([ "boolean", + "container", "dateTime", "number", "relation", @@ -115,6 +116,9 @@ const Value = ({ case "boolean": return {value === true ? "✓" : "—"}; + case "container": + return {String(value)}; + case "dateTime": return ; diff --git a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx index 987dd5e080..0d850dff0b 100644 --- a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx @@ -42,6 +42,7 @@ interface SkeletonFieldSource { const controlOfKind: Record = { boolean: "switch", + container: "input", dateTime: "input", enum: "input", file: "media", diff --git a/packages/vitnode/src/views/admin/views/content/lib/container-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/container-field.tsx new file mode 100644 index 0000000000..4a6226b2a8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/container-field.tsx @@ -0,0 +1,144 @@ +import { useQuery } from "@tanstack/react-query"; +import { RotateCwIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import { AutoFormLabel } from "@/components/form/common/label"; +import { Button } from "@/components/ui/button"; +import { FormMessage } from "@/components/ui/form"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + contentFrontendRegistry, + hasContentFrontendRegistry, +} from "@/content/admin/registry"; +import { isAdminRequestError } from "@/views/admin/admin-request"; +import { contentContainerListQuery } from "@/views/admin/views/containers/container-query"; +import { contentContainerApiTarget } from "@/views/admin/views/containers/container-request"; +import { + AutoFormContainerSelect, + containerSelectOptions, +} from "@/views/admin/views/containers/container-select"; + +export const useContentContainerOptions = ( + definitionId: string | undefined, +) => { + const entry = + definitionId !== undefined && hasContentFrontendRegistry() + ? contentFrontendRegistry().containers.byId(definitionId) + : undefined; + const query = useQuery({ + ...contentContainerListQuery({ + definitionId: definitionId ?? "", + target: entry + ? contentContainerApiTarget(entry) + : { permissionModule: "", pluginId: "" }, + }), + enabled: entry !== undefined, + }); + + return { entry, query }; +}; + +const ContainerFieldProblem = ({ + label, + message, + onRetry, +}: { + label: string; + message: string; + onRetry?: () => void; +}) => { + const t = useTranslations("core.content.form.container"); + + return ( + <> + {label} +
    +

    + {message} +

    + {onRetry ? ( + + ) : null} +
    + + + ); +}; + +export const ContentContainerField = ({ + spec, + ...props +}: ItemAutoFormComponentProps & { spec: ContentFormFieldSpec }) => { + const t = useTranslations("core.content.form.container"); + const { entry, query } = useContentContainerOptions( + spec.containerDefinitionId, + ); + + if (!entry) { + return ( + + ); + } + + if (query.isPending) { + return ( + <> + {spec.label} + + + ); + } + + if (query.isError) { + const forbidden = + isAdminRequestError(query.error) && query.error.status === 403; + + return ( + { + void query.refetch(); + } + } + /> + ); + } + + const options = containerSelectOptions(query.data); + + if (options.length === 0) { + return ; + } + + return ( + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index 7360739b7e..c173b48b75 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -23,6 +23,7 @@ import { AutoFormSwitch } from "@/components/form/fields/switch"; import { AutoFormTextarea } from "@/components/form/fields/textarea"; import { ContentFormFieldSkeleton } from "../form/skeleton"; +import { ContentContainerField } from "./container-field"; import { ContentGroupField } from "./group-field"; import { ContentOptionSwatch } from "./option-swatch"; import { contentOptionsQueryKey } from "./options-query"; @@ -222,6 +223,9 @@ export const ContentField = ({ case "boolean": return ; + case "container": + return ; + case "dateTime": return ; diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index 612731648b..e69d0c6b90 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -23,6 +23,7 @@ import { isContentPublished } from "@/content/publication"; import { isRichTextDocument } from "@/content/rich-text/document"; import { richTextToPlainText } from "@/content/rich-text/plain-text"; +import { useContentContainerOptions } from "../lib/container-field"; import { ContentHiddenIcon } from "../lib/hidden-badge"; export interface ContentRowData extends Record { @@ -152,6 +153,23 @@ const ContentReferenceList = ({ ); }; +const ContentContainerCellValue = ({ + definitionId, + value, +}: { + definitionId: string | undefined; + value: number; +}) => { + const { query } = useContentContainerOptions(definitionId); + const name = query.data?.find(container => container.id === value)?.name; + + return name === undefined ? ( + #{value} + ) : ( + {name} + ); +}; + export interface ContentStatusLabels { draft: string; published: string; @@ -220,6 +238,16 @@ const ContentCellValue = ({ ); + case "container": + return typeof value === "number" ? ( + + ) : ( + + ); + case "dateTime": return ; diff --git a/plugins/example/src/admin/content.tsx b/plugins/example/src/admin/content.tsx index 0ddb8ed672..e2c9c32b22 100644 --- a/plugins/example/src/admin/content.tsx +++ b/plugins/example/src/admin/content.tsx @@ -4,6 +4,7 @@ import { CONFIG_PLUGIN } from "@/const"; import { exampleArticleNav, + exampleBoardsNav, exampleCategoryNav, exampleDiscussionNav, exampleEventNav, @@ -13,6 +14,7 @@ import { export const adminContent = { pluginId: CONFIG_PLUGIN.pluginId, + containers: [exampleBoardsNav], contentTypes: [ exampleArticleNav, exampleCategoryNav, diff --git a/plugins/example/src/admin/nav.tsx b/plugins/example/src/admin/nav.tsx index 70ceaaa574..a4e24cf8ac 100644 --- a/plugins/example/src/admin/nav.tsx +++ b/plugins/example/src/admin/nav.tsx @@ -4,6 +4,7 @@ import { CalendarDaysIcon, FlaskConicalIcon, FolderIcon, + FolderTreeIcon, LayoutTemplateIcon, MessagesSquareIcon, NotebookPenIcon, @@ -12,6 +13,7 @@ import { import { CONFIG_PLUGIN } from "@/const"; import { articleContentType } from "@/content/article"; +import { boardContainers } from "@/content/boards"; import { categoryContentType } from "@/content/category"; import { discussionContentType } from "@/content/discussion"; import { eventContentType } from "@/content/event"; @@ -50,8 +52,14 @@ export const exampleDiscussionNav = { icon: , }; +export const exampleBoardsNav = { + definition: boardContainers, + icon: , +}; + export const adminNav = { pluginId: CONFIG_PLUGIN.pluginId, + containers: [exampleBoardsNav], contentTypes: [ exampleArticleNav, exampleCategoryNav, diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts index 8a8a5c8137..4466c5b2cb 100644 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@vitnode/core/api/lib/module"; import { buildContentAdminModule } from "@vitnode/core/content/server"; import { CONFIG_PLUGIN } from "@/const"; +import { boardContainers } from "@/content/boards"; import { advancedArticleContent } from "@/database/advanced-articles"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; @@ -18,6 +19,7 @@ export const adminModule = buildModule({ modules: [ buildContentAdminModule({ pluginId: CONFIG_PLUGIN.pluginId, + containers: [boardContainers], // The localized article is registered on the API side only, so its // generated CRUD *and* translation routes exist and its staff permissions // are derived - but it gets no AdminCP screen yet, because a form that diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index e34b6197ff..1a68b8c30e 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -7,6 +7,7 @@ import { articleExcerptAiAction } from "@/api/ai/actions"; import { exampleCategoryScope } from "@/api/lib/moderation"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; +import { boardContainers } from "@/content/boards"; import { settingsPage } from "@/content/settings-page"; import { advancedArticleContent } from "@/database/advanced-articles"; import { articleContent } from "@/database/articles"; @@ -37,6 +38,7 @@ export const exampleApiPlugin = () => adminModule, buildContentPublicModule({ pluginId: CONFIG_PLUGIN.pluginId, + containers: [boardContainers], contentTypes: [ advancedArticleContent, articleContent, diff --git a/plugins/example/src/content/boards.ts b/plugins/example/src/content/boards.ts new file mode 100644 index 0000000000..da79e8da08 --- /dev/null +++ b/plugins/example/src/content/boards.ts @@ -0,0 +1,22 @@ +import { defineContentContainer, field } from "@vitnode/core/content"; + +export const boardContainers = defineContentContainer({ + id: "example.boards", + + fields: { + description: field.textarea({ maxLength: 500, nullable: true }), + color: field.text({ maxLength: 7, nullable: true }), + icon: field.text({ maxLength: 64, nullable: true }), + locked: field.boolean({ defaultValue: false }), + }, + + hierarchy: { maxDepth: 3 }, + ordering: { mode: "manual" }, + + publicApi: { + path: "boards", + fields: ["description", "color", "icon"], + }, + + admin: { path: "example/boards" }, +}); diff --git a/plugins/example/src/content/discussion.ts b/plugins/example/src/content/discussion.ts index 5e1c0ecf5f..f955e27b30 100644 --- a/plugins/example/src/content/discussion.ts +++ b/plugins/example/src/content/discussion.ts @@ -1,5 +1,7 @@ import { defineContentType, field } from "@vitnode/core/content"; +import { boardContainers } from "@/content/boards"; + export const discussionContentType = defineContentType({ id: "example.discussion", tableName: "example_discussions", @@ -9,6 +11,7 @@ export const discussionContentType = defineContentType({ slug: field.slug({ source: "title" }), body: field.richText({ required: true }), author: field.user(), + board: field.container({ definition: boardContainers, required: true }), }, publication: true, @@ -17,12 +20,13 @@ export const discussionContentType = defineContentType({ publicApi: { path: "discussions", - fields: ["id", "title", "slug", "body", "publishedAt"], + fields: ["id", "title", "slug", "body", "board", "publishedAt"], + filterableFields: ["board"], }, publicWrite: { ownership: { field: "author" }, - create: { fields: ["title", "body"], status: "published" }, + create: { fields: ["title", "body", "board"], status: "published" }, update: { fields: ["title", "body"] }, }, @@ -34,6 +38,7 @@ export const discussionContentType = defineContentType({ "status", "title", "slug", + "board", "author", "publishedAt", "updatedAt", diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 5561df84a8..3e037b5570 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -105,7 +105,21 @@ "body": "Message", "author": "Author", "publishedAt": "Published", - "updatedAt": "Updated" + "updatedAt": "Updated", + "board": "Board" + } + } + }, + "containers": { + "boards": { + "label": "{count, plural, one {Board} other {Boards}}", + "title": "Boards", + "desc": "The board tree members file their discussions into. Drag to reorder or nest boards.", + "fields": { + "description": "Description", + "color": "Color", + "icon": "Icon", + "locked": "Locked" } } }, @@ -151,11 +165,41 @@ "desc": "Limit this permission to articles in some categories." } }, + "boards": { + "title": "Boards", + "desc": "Find the right place for your question, or start a new discussion.", + "tree": "All boards", + "children": "Boards inside", + "discussions": "Discussions", + "new_discussion": "New discussion", + "pagination": "Discussion pages", + "newest": "Newest discussions", + "older": "Older discussions", + "empty": { + "title": "No boards yet", + "desc": "An admin has not created any boards. Once they do, they show up here.", + "home": "Back to home" + }, + "empty_board": { + "title": "Nothing here yet", + "desc": "Be the first to start a discussion in this board." + }, + "not_found": { + "title": "Board not found", + "desc": "It may have been removed, or the link is wrong.", + "back": "See all boards" + } + }, "discussions": { "new": { "title": "Start a discussion", "desc": "Ask a question or share an idea. Everyone can read it as soon as you post it.", - "submit": "Post discussion" + "submit": "Post discussion", + "no_boards": { + "title": "No boards yet", + "desc": "Discussions live in boards, and an admin has not created one yet. Check back soon.", + "action": "Browse boards" + } }, "edit": { "title": "Edit discussion", @@ -179,9 +223,17 @@ "created": { "title": "Discussion posted", "desc": "It is live. Use Edit whenever you want to change it." - } + }, + "board": "Board", + "boards": "Boards" }, "form": { + "board": { + "label": "Board", + "desc": "Pick where the discussion belongs. You can't move it later.", + "placeholder": "Choose a board", + "required": "Pick a board" + }, "title": { "label": "Title", "min": "Use at least 3 characters", @@ -219,7 +271,8 @@ "unknown": "Something went wrong on our side. Please try again.", "issues": { "not_writable": "This field can't be changed here.", - "unavailable": "The selected item is no longer available." + "unavailable": "The selected item is no longer available.", + "board_unavailable": "This board is no longer available. Pick another one." } } } @@ -260,6 +313,11 @@ "@vitnode/example:discussion:can_publish": "Publish and unpublish discussions", "@vitnode/example:discussion:can_restore": "Restore an earlier version of a discussion", "@vitnode/example:discussion:can_hide": "Hide and unhide discussions", + "@vitnode/example:example_boards": "Boards", + "@vitnode/example:example_boards:can_view": "View boards", + "@vitnode/example:example_boards:can_create": "Create boards", + "@vitnode/example:example_boards:can_edit": "Edit and move boards", + "@vitnode/example:example_boards:can_delete": "Delete boards", "ai_actions": { "@vitnode/example": { "article_excerpt": { diff --git a/plugins/example/src/pages/board-page.tsx b/plugins/example/src/pages/board-page.tsx new file mode 100644 index 0000000000..9dc0cee8aa --- /dev/null +++ b/plugins/example/src/pages/board-page.tsx @@ -0,0 +1,300 @@ +import { DateFormat } from "@vitnode/core/components/date-format"; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@vitnode/core/components/ui/breadcrumb"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@vitnode/core/components/ui/empty"; +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { MessagesSquareIcon, SearchXIcon } from "lucide-react"; +import { Fragment } from "react"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import type { BoardSearch } from "./board-search"; + +import { boardHref, BOARDS_HREF } from "./board-search"; +import { BoardColorDot, BoardTree } from "./board-tree"; +import { discussionHref, discussionNewHref } from "./discussion-search"; + +const PAGE_SIZE = 20; + +const zodDiscussionRow = z.object({ + id: z.number(), + publishedAt: z.string().nullable(), + slug: z.string().nullable(), + title: z.string(), +}); + +const loadBoard = async (idOrSlug: string, { cursor }: BoardSearch) => { + const detail = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/boards", + path: "/{idOrSlug}", + args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } }, + }); + + if (detail.status !== 200 && detail.status !== 404) { + throw new Error("Loading the board failed."); + } + + const { ancestors, children, container } = contentPageItem( + detail.status === 200 ? await detail.json() : null, + ); + + const filters = { + board: String(container.id), + includeDescendants: "true", + }; + + const list = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/discussions", + path: "/", + args: { + query: { + ...filters, + first: String(PAGE_SIZE), + ...(cursor ? { cursor } : {}), + }, + }, + }); + + if (list.status !== 200) throw new Error("Loading the discussions failed."); + + const { edges, pageInfo } = await list.json(); + + return { + ancestors, + children, + container, + discussions: z.array(zodDiscussionRow).parse(edges), + isFirstPage: cursor === undefined, + nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, + }; +}; + +type BoardPageData = Awaited>; + +const BoardNotFound = () => { + const t = useTranslations("@vitnode/example.boards.not_found"); + + return ( +
    + + + + + }>{t("title")} + {t("desc")} + + + + {t("back")} + + + +
    + ); +}; + +const BoardsCrumb = () => { + const t = useTranslations("@vitnode/example.boards"); + + return <>{t("title")}; +}; + +export const route = definePluginRoute({ + load: async ({ params, search }) => await loadBoard(params.slug, search), + + head: ({ loaderData }) => ({ + description: loaderData?.container.metadata.description ?? undefined, + title: loaderData?.container.name, + }), + + breadcrumb: { + group: ({ loaderData }) => ( + <> + + + + + + {loaderData.ancestors.map(ancestor => ( + + + + + {ancestor.name} + + + + ))} + + + {loaderData.container.name} + + + ), + }, + + notFound: BoardNotFound, +}); + +const BoardPage = ({ + loaderData, +}: PluginRoutePageProps) => { + const t = useTranslations("@vitnode/example.boards"); + const { children, container, discussions, isFirstPage, nextCursor } = + loaderData; + + return ( +
    +
    +
    +

    + + {container.name} +

    + {container.metadata.description ? ( +

    + {container.metadata.description} +

    + ) : null} +
    + + + {t("new_discussion")} + +
    + + {children.length > 0 ? ( +
    +

    + {t("children")} +

    + ({ ...child, children: [] }))} + /> +
    + ) : null} + +
    +

    + {t("discussions")} +

    + + {discussions.length === 0 ? ( + + + + + }>{t("empty_board.title")} + {t("empty_board.desc")} + + + + {t("new_discussion")} + + + + ) : ( +
      + {discussions.map(discussion => ( +
    • + {discussion.slug === null ? ( + + {discussion.title} + + ) : ( + + {discussion.title} + + )} + {discussion.publishedAt ? ( + + ) : null} +
    • + ))} +
    + )} + + {isFirstPage && nextCursor === null ? null : ( + + )} +
    +
    + ); +}; + +export default BoardPage; diff --git a/plugins/example/src/pages/board-search.ts b/plugins/example/src/pages/board-search.ts new file mode 100644 index 0000000000..28dcd9f566 --- /dev/null +++ b/plugins/example/src/pages/board-search.ts @@ -0,0 +1,15 @@ +export interface BoardSearch { + cursor?: string; +} + +const CURSOR = /^[A-Za-z0-9_-]{1,512}$/; + +export const boardSearch = (input: Record): BoardSearch => + typeof input.cursor === "string" && CURSOR.test(input.cursor) + ? { cursor: input.cursor } + : {}; + +export const BOARDS_HREF = "/boards"; + +export const boardHref = (slug: string, { cursor }: BoardSearch = {}): string => + `/boards/${encodeURIComponent(slug)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`; diff --git a/plugins/example/src/pages/board-tree.tsx b/plugins/example/src/pages/board-tree.tsx new file mode 100644 index 0000000000..8c6ac9f150 --- /dev/null +++ b/plugins/example/src/pages/board-tree.tsx @@ -0,0 +1,63 @@ +import type { ContentContainerTreeNode } from "@vitnode/core/content"; + +import type { Board } from "./boards-data"; + +import { boardHref } from "./board-search"; +import { boardColor } from "./boards-data"; + +export const BoardColorDot = ({ board }: { board: Board }) => { + const color = boardColor(board); + + return ( +