+ → Route Handler (web)
+ → revalidateTag(tag, { expire: 0 })
+```
+
+Mount the handler once per web app - it is in the scaffold already:
+
+```ts title="src/app/api/vitnode/content/revalidate/route.ts"
+export { POST } from "@vitnode/core/content/next/revalidate-route";
+```
+
+| Concern | Answer |
+| --- | --- |
+| Auth | `Bearer CRON_SECRET`, compared with `timingSafeEqual`. Already documented as the secret "for internal API calls", already flagged when insecure |
+| Replay | A `±5 minute` timestamp window. Replaying a revalidation only expires a tag again, so a nonce store would be a table guarding nothing |
+| Which origin | `NEXT_PUBLIC_WEB_URL` by default; `buildApiConfig({ content: { revalidateOrigins: [...] } })` for several front ends, each posted independently |
+| Failure | Two attempts inside the bridge, then the **effects task** retries the whole delivery on the queue's backoff |
+
+
+ The bridge itself is best effort and never throws - but the task that calls it
+ fails unless **every** origin accepted the request, so the queue retries it.
+ Because that task never republishes, a web app that was redeploying for two
+ minutes gets its tags expired on the next attempt instead of never. That is
+ the whole reason the effects are a task of their own.
+
+A retry re-posts to every origin, including the ones that already succeeded.
+Expiring an already-expired tag is a no-op, so that is cheaper than tracking
+which of them worked.
+
+
+
+### `immediate` from a Route Handler
+
+`updateTag` is Server-Action-only - that restriction is what buys
+read-your-own-writes. `revalidateTag(tag, { expire: 0 })` is the documented
+webhook equivalent, so `revalidateContent` takes a `context` and picks the
+strongest option available where it is called:
+
+```ts
+revalidateContent(input, { context: "route-handler", mode: "immediate" });
+```
+
+| context | mode | Calls |
+| --- | --- | --- |
+| `server-action` (default) | `immediate` | `updateTag(tag)` |
+| `route-handler` | `immediate` | `revalidateTag(tag, { expire: 0 })` |
+| either | `stale-while-revalidate` | `revalidateTag(tag, "max")` |
+
+Nothing about the existing Server Action paths changed.
+
+## The AdminCP
+
+A calendar icon leads the actions cell for a schedulable content type. The
+dialog shows what is booked, what already ran and who booked it, plus a form
+with two fields: what should happen, and when.
+
+- The date field says which timezone it is reading, because "9am" is a question
+ otherwise.
+- A pending schedule can be cancelled from the same list - up until the moment
+ the worker claims it, after which the cancel answers 404 because the schedule
+ already ran.
+- A pending schedule whose time has passed is marked **overdue**, with the last
+ error if there was one - that is the shape a failed run takes, since there is
+ no `failed` status.
+- A completed schedule whose announcements have not landed says so, in a
+ different colour and different words: the record *is* published.
+- Without a cron adapter, a warning sits above everything.
+
+## Retention
+
+Completed and cancelled rows are **kept**. "Who scheduled this, and when did it
+go out" is the audit trail the feature exists to provide; deleting it the moment
+it succeeds would answer that question with silence.
+
+A daily core cron (`content-editorial-cleanup`) removes settled rows past 30
+days, and rows whose content type is no longer registered at all.
+
+## What it does not do
+
+| Not supported | Why |
+| --- | --- |
+| To-the-second precision | The queue drains on a one-minute tick |
+| Scheduling a **field edit** | Only `status` moves. A content change is an edit, and edits are immediate |
+| Freezing what goes live | The schedule publishes the record as it stands at that moment |
+| Recurring schedules | One row, one time, one action |
+| A `failed` status | An overdue `pending` row with `lastError` says the same thing with one fewer state that can be wrong |
+| Exactly-once events | Effects are retried as a unit, so a listener can see one `published` twice. Key off `scheduleId` or `revisionId` if that matters |
+| Cancelling a schedule that is mid-flight | The lock is held to the commit, so the cancel waits and then honestly reports that it ran |
+| Firing without a cron adapter | Nothing drains the queue. The UI warns |
diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx
index d8b65edcd..d4a51293e 100644
--- a/apps/docs/content/docs/dev/content-engine/schemas.mdx
+++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx
@@ -75,9 +75,16 @@ Describes the response, including `id`, `createdAt` and `updatedAt`. Dates are
route extends with the joined relation labels.
With [publication](/docs/dev/content-engine/publication) it also carries
-`status` and `publishedAt`. Both are absent from `create` and `update`, which
-are strict - so publishing is only ever reachable through `service.publish` and
-its route, never through a field update.
+`status` and `publishedAt`, and with
+[editorial](/docs/dev/content-engine/editorial) it also carries `version`. All
+three are absent from `create` and `update`, which are strict - so publishing is
+only ever reachable through `service.publish` and its route, and `version`
+belongs to the engine alone.
+
+That strictness is also what makes the
+[update envelope](/docs/dev/content-engine/revisions#the-envelope) look the way
+it does: `expectedVersion` travels *beside* `values`, never inside it, because
+`update` would reject it.
## publicSelect
diff --git a/apps/docs/content/docs/dev/content-engine/service.mdx b/apps/docs/content/docs/dev/content-engine/service.mdx
index 89e8bf29a..621ba6acd 100644
--- a/apps/docs/content/docs/dev/content-engine/service.mdx
+++ b/apps/docs/content/docs/dev/content-engine/service.mdx
@@ -253,6 +253,31 @@ and they expire no [cache tag](/docs/dev/content-engine/caching) either. A
service call that runs inside your transaction cannot honestly announce anything
until you commit, so the follow-up is yours.
+## The editorial service is a different object
+
+A content type with [`editorial`](/docs/dev/content-engine/editorial) also
+exposes `model.editorialService`, and the generated routes use **that** one for
+every write. It is not a wrapper you can ignore: it opens a transaction, guards
+the write with `expectedVersion`, bumps `version` and captures a revision, all in
+one commit.
+
+```ts
+const editorial = articleContent.editorialService?.(c, { pluginId });
+if (!editorial) throw new HTTPException(404);
+
+const outcome = await editorial.update(
+ 7,
+ { title: "Updated" },
+ { actor: { type: "staff", userId: 1 }, expectedVersion: 12 },
+);
+```
+
+`model.service(c)` still works on an editorial content type and still writes
+rows - but it does not lock, does not bump the version and records no history.
+That is the right tool for a migration or a backfill, and the wrong one for
+anything a person did. Full contract in
+[Revisions and locking](/docs/dev/content-engine/revisions#calling-the-service-directly).
+
## options
Backs the relation and user pickers, capped and search-filtered. It only accepts
diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx
index 095f08b22..6caa27d6a 100644
--- a/apps/docs/content/docs/dev/events/built-in-events.mdx
+++ b/apps/docs/content/docs/dev/events/built-in-events.mdx
@@ -250,6 +250,16 @@ content.example.article.published
content.example.article.unpublished
```
+And one that opts into
+[`editorial`](/docs/dev/content-engine/editorial) emits one more, plus two with
+[`editorial.scheduling`](/docs/dev/content-engine/scheduling):
+
+```text
+content.example.article.restored
+content.example.article.scheduled
+content.example.article.schedule_cancelled
+```
+
They are registered on the global map by the owning plugin with a single
`declare module` block, so the names and payloads are as strongly typed as any
core event - `changedFields` narrows to that content type's own field names.
@@ -270,21 +280,129 @@ core event - `changedFields` narrows to that content type's own field names.
"Published only - when the row first went live. Never rewritten by a later unpublish/republish.",
type: "Date",
},
+ version: {
+ description:
+ "Restored only - the version the record holds after the restore. Always a new number; the restored version is never reinstated.",
+ type: "number",
+ },
+ revisionId: {
+ description: "Restored only - the revision this restore itself created.",
+ type: "number",
+ },
+ restoredFromRevisionId: {
+ description: "Restored only - the revision the values were taken from.",
+ type: "number",
+ },
+ scheduleId: {
+ description:
+ "The schedule row this is about. Always present on scheduled and schedule_cancelled; present on published and unpublished only when a schedule fired them, where it doubles as the idempotency key for at-least-once retries.",
+ type: "number",
+ },
+ scheduledFor: {
+ description: "Scheduled only - when the transition will happen.",
+ type: "Date",
+ },
+ action: {
+ description:
+ "Scheduled and schedule_cancelled only - which transition was booked.",
+ type: '"publish" | "unpublish"',
+ },
+ scheduledBy: {
+ description:
+ "Published and unpublished only, and only when a schedule fired them - the person who booked it. Absent on an interactive publish.",
+ type: "null | number",
+ },
}}
/>
-The envelope already carries the actor, the emitting plugin and the timestamp,
-so the payloads stay minimal.
+Booking a schedule changes no field value, so it consumes no version and writes
+no revision - `scheduled` is a different thing from `published`. When the
+schedule fires, the resulting transition emits the ordinary `published` or
+`unpublished` with `scheduledBy` and `scheduleId` set.
+
+### A scheduled event may arrive twice
+
+Announcements for a scheduled transition run in a
+[durable queue task](/docs/dev/content-engine/scheduling#all-three-have-to-land)
+that retries whenever the event, the search write or a cache origin failed - and
+a retry re-emits an event that some listeners already received. Delivery is
+**at-least-once**, deliberately: the alternative is a transactional outbox,
+which Stage 4 does not have.
+
+A listener whose work must happen exactly once keys off `scheduleId`, which is
+stable across every attempt at the same booking:
+
+```ts
+handler: async (c, payload) => {
+ if (payload.scheduleId && (await alreadyDone(payload.scheduleId))) return;
+
+ await sendTheAnnouncement(payload.contentId);
+};
+```
+
+An interactive publish is emitted once, by the route that performed it, and
+carries no `scheduleId`.
+
+### The envelope's owner is the content type's plugin
+
+`pluginId` on the envelope answers "whose event is this", not "who was running
+at the time". Those come apart the moment something happens on a schedule: core
+owns the queue handler, so `c.get("plugin")` says `@vitnode/core`, while
+`content.example.article.published` belongs to the example plugin as much as it
+ever did.
+
+```text
+queue task owner @vitnode/core ← who runs the handler
+event envelope @vitnode/example ← who owns the content type
+```
+
+The Content Engine passes the owner explicitly on every emit, so ownership does
+not depend on which route module or queue handler invoked it. Your own code can
+do the same when it emits on someone else's behalf:
+
+```ts
+await c.get("events").emit("blog.post.created", payload, {
+ pluginId: "@vitnode/blog",
+});
+```
+
+Omit the option and nothing changes: the envelope falls back to
+`c.get("plugin")` and then to `@vitnode/core`, exactly as before. Pass it
+rather than swapping `c.get("plugin")` - that context is shared with the
+logger, the permission checks and every other model on the request.
+
+The envelope also carries the actor and the timestamp, so the payloads stay
+minimal.
+
+### Failures are reported, not thrown
+
+`emit()` never throws. Listeners run after the write it describes has
+committed, and a broken listener is not a reason to tell somebody their save
+failed - so a failure comes back in the result instead:
+
+```ts
+const result = await c.get("events").emit("blog.post.created", payload);
+
+result.delivered; // listeners that ran
+result.failures; // [{ pluginId, module, listener, error }]
+```
+
+Interactive routes ignore that result on purpose, because the mutation
+succeeded either way. Background work usually should not: the scheduled-effects
+task inspects `failures` and retries the whole delivery when it is non-empty.
These are emitted by the **generated routes**, not by the Content Service. A
route emits exactly one event per successful mutation, after the database write
has returned - a failed validation, a delete blocked by a foreign key, a no-op
-update and a no-op publish all emit nothing, and `published`/`unpublished` never
-come with an `updated` alongside them. Calling `service.publish()` or any other
-service method directly changes the database and emits nothing; that code owns
-its own follow-up. See
+update, a no-op publish and a restore that changes nothing all emit nothing, and
+`published`, `unpublished` and `restored` never come with an `updated` alongside
+them. Calling `service.publish()` or any other service method directly changes
+the database and emits nothing; that code owns its own follow-up. See
[Generated events](/docs/dev/content-engine/events#calling-the-service-directly).
+`restored` carries `changedFields` exactly like `updated`, so a listener written
+for one ports to the other in a line - which is why a restore does not emit both.
+
**Use cases:** reindex the row for search, invalidate a CDN entry, or mirror the
change into a plugin-owned projection. See
[Generated events](/docs/dev/content-engine/events).
diff --git a/apps/docs/migrations/0025_add_content_revisions.sql b/apps/docs/migrations/0025_add_content_revisions.sql
new file mode 100644
index 000000000..973e967a3
--- /dev/null
+++ b/apps/docs/migrations/0025_add_content_revisions.sql
@@ -0,0 +1,20 @@
+CREATE TABLE "core_content_revisions" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "pluginId" varchar(255) NOT NULL,
+ "contentTypeId" varchar(100) NOT NULL,
+ "itemId" integer NOT NULL,
+ "version" integer NOT NULL,
+ "operation" varchar(20) NOT NULL,
+ "snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "changedFields" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "actorType" varchar(16) DEFAULT 'system' NOT NULL,
+ "actorUserId" integer,
+ "restoredFromRevisionId" integer,
+ "createdAt" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "core_content_revisions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "core_content_revisions" ADD CONSTRAINT "core_content_revisions_actorUserId_core_users_id_fk" FOREIGN KEY ("actorUserId") REFERENCES "public"."core_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
+CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","version");--> statement-breakpoint
+CREATE INDEX "core_content_revisions_plugin_id_idx" ON "core_content_revisions" USING btree ("pluginId");--> statement-breakpoint
+CREATE INDEX "core_content_revisions_actor_user_id_idx" ON "core_content_revisions" USING btree ("actorUserId");
\ No newline at end of file
diff --git a/apps/docs/migrations/0026_add_example_article_editorial.sql b/apps/docs/migrations/0026_add_example_article_editorial.sql
new file mode 100644
index 000000000..99ae91f24
--- /dev/null
+++ b/apps/docs/migrations/0026_add_example_article_editorial.sql
@@ -0,0 +1 @@
+ALTER TABLE "example_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;
\ No newline at end of file
diff --git a/apps/docs/migrations/0027_add_content_schedules.sql b/apps/docs/migrations/0027_add_content_schedules.sql
new file mode 100644
index 000000000..5fb3475f1
--- /dev/null
+++ b/apps/docs/migrations/0027_add_content_schedules.sql
@@ -0,0 +1,23 @@
+CREATE TABLE "core_content_schedules" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "pluginId" varchar(255) NOT NULL,
+ "contentTypeId" varchar(100) NOT NULL,
+ "itemId" integer NOT NULL,
+ "action" varchar(16) NOT NULL,
+ "scheduledFor" timestamp NOT NULL,
+ "generation" integer DEFAULT 1 NOT NULL,
+ "status" varchar(16) DEFAULT 'pending' NOT NULL,
+ "createdBy" integer,
+ "createdAt" timestamp DEFAULT now() NOT NULL,
+ "updatedAt" timestamp DEFAULT now() NOT NULL,
+ "completedAt" timestamp,
+ "lastError" text
+);
+--> statement-breakpoint
+ALTER TABLE "core_content_schedules" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "core_content_schedules" ADD CONSTRAINT "core_content_schedules_createdBy_core_users_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."core_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
+CREATE UNIQUE INDEX "core_content_schedules_active_unique" ON "core_content_schedules" USING btree ("contentTypeId","itemId","action") WHERE status = 'pending';--> statement-breakpoint
+CREATE INDEX "core_content_schedules_due_idx" ON "core_content_schedules" USING btree ("status","scheduledFor");--> statement-breakpoint
+CREATE INDEX "core_content_schedules_item_idx" ON "core_content_schedules" USING btree ("contentTypeId","itemId");--> statement-breakpoint
+CREATE INDEX "core_content_schedules_plugin_id_idx" ON "core_content_schedules" USING btree ("pluginId");--> statement-breakpoint
+CREATE INDEX "core_content_schedules_created_by_idx" ON "core_content_schedules" USING btree ("createdBy");
\ No newline at end of file
diff --git a/apps/docs/migrations/0028_add_content_schedule_effects_error.sql b/apps/docs/migrations/0028_add_content_schedule_effects_error.sql
new file mode 100644
index 000000000..f53e168a7
--- /dev/null
+++ b/apps/docs/migrations/0028_add_content_schedule_effects_error.sql
@@ -0,0 +1 @@
+ALTER TABLE "core_content_schedules" ADD COLUMN "effectsError" text;
\ No newline at end of file
diff --git a/apps/docs/migrations/meta/0025_snapshot.json b/apps/docs/migrations/meta/0025_snapshot.json
new file mode 100644
index 000000000..ba89f1462
--- /dev/null
+++ b/apps/docs/migrations/meta/0025_snapshot.json
@@ -0,0 +1,2696 @@
+{
+ "id": "4f1c20aa-86f5-4d5b-9b4f-75101d57d8de",
+ "prevId": "f73d7a47-f42f-4629-8af1-b388c220a427",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.core_admin_permissions": {
+ "name": "core_admin_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_admin_permissions_role_id_idx": {
+ "name": "core_admin_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_permissions_user_id_idx": {
+ "name": "core_admin_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_permissions_roleId_core_roles_id_fk": {
+ "name": "core_admin_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_permissions_userId_core_users_id_fk": {
+ "name": "core_admin_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_sessions": {
+ "name": "core_admin_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_admin_sessions_token_idx": {
+ "name": "core_admin_sessions_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_sessions_user_id_idx": {
+ "name": "core_admin_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_sessions_userId_core_users_id_fk": {
+ "name": "core_admin_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_sessions_token_unique": {
+ "name": "core_admin_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_content_revisions": {
+ "name": "core_content_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contentTypeId": {
+ "name": "contentTypeId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "changedFields": {
+ "name": "changedFields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "actorType": {
+ "name": "actorType",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actorUserId": {
+ "name": "actorUserId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restoredFromRevisionId": {
+ "name": "restoredFromRevisionId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_content_revisions_item_version_unique": {
+ "name": "core_content_revisions_item_version_unique",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_plugin_id_idx": {
+ "name": "core_content_revisions_plugin_id_idx",
+ "columns": [
+ {
+ "expression": "pluginId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_actor_user_id_idx": {
+ "name": "core_content_revisions_actor_user_id_idx",
+ "columns": [
+ {
+ "expression": "actorUserId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_content_revisions_actorUserId_core_users_id_fk": {
+ "name": "core_content_revisions_actorUserId_core_users_id_fk",
+ "tableFrom": "core_content_revisions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "actorUserId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_cron": {
+ "name": "core_cron",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastRun": {
+ "name": "lastRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "module": {
+ "name": "module",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nextRun": {
+ "name": "nextRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_dashboard": {
+ "name": "core_admin_dashboard",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "widgets": {
+ "name": "widgets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_admin_dashboard_user_id_idx": {
+ "name": "core_admin_dashboard_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_dashboard_userId_core_users_id_fk": {
+ "name": "core_admin_dashboard_userId_core_users_id_fk",
+ "tableFrom": "core_admin_dashboard",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_dashboard_userId_unique": {
+ "name": "core_admin_dashboard_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_files": {
+ "name": "core_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mimeType": {
+ "name": "mimeType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_files_user_id_idx": {
+ "name": "core_files_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_files_userId_core_users_id_fk": {
+ "name": "core_files_userId_core_users_id_fk",
+ "tableFrom": "core_files",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_files_key_unique": {
+ "name": "core_files_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages": {
+ "name": "core_languages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time24": {
+ "name": "time24",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {
+ "core_languages_code_idx": {
+ "name": "core_languages_code_idx",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_languages_name_idx": {
+ "name": "core_languages_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_languages_code_unique": {
+ "name": "core_languages_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages_words": {
+ "name": "core_languages_words",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pluginCode": {
+ "name": "pluginCode",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tableName": {
+ "name": "tableName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "variable": {
+ "name": "variable",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_languages_words_lang_code_idx": {
+ "name": "core_languages_words_lang_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_languages_words_languageCode_core_languages_code_fk": {
+ "name": "core_languages_words_languageCode_core_languages_code_fk",
+ "tableFrom": "core_languages_words",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "languageCode"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_logs": {
+ "name": "core_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'localhost'"
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 500
+ },
+ "userId": {
+ "name": "userId",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "test123": {
+ "name": "test123",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_logs_userId_core_users_id_fk": {
+ "name": "core_logs_userId_core_users_id_fk",
+ "tableFrom": "core_logs",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_moderators_permissions": {
+ "name": "core_moderators_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_moderators_permissions_role_id_idx": {
+ "name": "core_moderators_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_moderators_permissions_user_id_idx": {
+ "name": "core_moderators_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_moderators_permissions_roleId_core_roles_id_fk": {
+ "name": "core_moderators_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_moderators_permissions_userId_core_users_id_fk": {
+ "name": "core_moderators_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_queue": {
+ "name": "core_queue",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queue": {
+ "name": "queue",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "maxAttempts": {
+ "name": "maxAttempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "availableAt": {
+ "name": "availableAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reservedAt": {
+ "name": "reservedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "core_queue_status_available_at_idx": {
+ "name": "core_queue_status_available_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "availableAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_roles": {
+ "name": "core_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "root": {
+ "name": "root",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "guest": {
+ "name": "guest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowUploadFiles": {
+ "name": "allowUploadFiles",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "totalMaxStorage": {
+ "name": "totalMaxStorage",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "maxStorageForSubmit": {
+ "name": "maxStorageForSubmit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_search_index": {
+ "name": "core_search_index",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemType": {
+ "name": "itemType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "search_vector": {
+ "name": "search_vector",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": true,
+ "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"
+ }
+ },
+ "containerType": {
+ "name": "containerType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "containerId": {
+ "name": "containerId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isPublic": {
+ "name": "isPublic",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "indexedAt": {
+ "name": "indexedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_search_index_search_vector_idx": {
+ "name": "core_search_index_search_vector_idx",
+ "columns": [
+ {
+ "expression": "search_vector",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "core_search_index_created_at_idx": {
+ "name": "core_search_index_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_author_id_idx": {
+ "name": "core_search_index_author_id_idx",
+ "columns": [
+ {
+ "expression": "authorId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_item_type_idx": {
+ "name": "core_search_index_item_type_idx",
+ "columns": [
+ {
+ "expression": "itemType",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_language_code_idx": {
+ "name": "core_search_index_language_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_is_public_idx": {
+ "name": "core_search_index_is_public_idx",
+ "columns": [
+ {
+ "expression": "isPublic",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_search_index_authorId_core_users_id_fk": {
+ "name": "core_search_index_authorId_core_users_id_fk",
+ "tableFrom": "core_search_index",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_search_index_item_unique": {
+ "name": "core_search_index_item_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "itemType",
+ "itemId",
+ "languageCode"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions": {
+ "name": "core_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_sessions_user_id_idx": {
+ "name": "core_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_sessions_userId_core_users_id_fk": {
+ "name": "core_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_token_unique": {
+ "name": "core_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions_known_devices": {
+ "name": "core_sessions_known_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "publicId": {
+ "name": "publicId",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_sessions_known_devices_ip_address_idx": {
+ "name": "core_sessions_known_devices_ip_address_idx",
+ "columns": [
+ {
+ "expression": "ipAddress",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_known_devices_publicId_unique": {
+ "name": "core_sessions_known_devices_publicId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "publicId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users": {
+ "name": "core_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "nameCode": {
+ "name": "nameCode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "newsletter": {
+ "name": "newsletter",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatarColor": {
+ "name": "avatarColor",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "core_users_name_code_idx": {
+ "name": "core_users_name_code_idx",
+ "columns": [
+ {
+ "expression": "nameCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_name_idx": {
+ "name": "core_users_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_email_idx": {
+ "name": "core_users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_roleId_core_roles_id_fk": {
+ "name": "core_users_roleId_core_roles_id_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "core_users_language_core_languages_code_fk": {
+ "name": "core_users_language_core_languages_code_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "language"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "set default",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_nameCode_unique": {
+ "name": "core_users_nameCode_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "nameCode"
+ ]
+ },
+ "core_users_name_unique": {
+ "name": "core_users_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ },
+ "core_users_email_unique": {
+ "name": "core_users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_confirm_emails": {
+ "name": "core_users_confirm_emails",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_confirm_emails_userId_core_users_id_fk": {
+ "name": "core_users_confirm_emails_userId_core_users_id_fk",
+ "tableFrom": "core_users_confirm_emails",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_confirm_emails_token_unique": {
+ "name": "core_users_confirm_emails_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_forgot_password": {
+ "name": "core_users_forgot_password",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_forgot_password_userId_core_users_id_fk": {
+ "name": "core_users_forgot_password_userId_core_users_id_fk",
+ "tableFrom": "core_users_forgot_password",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_forgot_password_userId_unique": {
+ "name": "core_users_forgot_password_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ },
+ "core_users_forgot_password_token_unique": {
+ "name": "core_users_forgot_password_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_secondary_roles": {
+ "name": "core_users_secondary_roles",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_secondary_roles_user_id_idx": {
+ "name": "core_users_secondary_roles_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_secondary_roles_role_id_idx": {
+ "name": "core_users_secondary_roles_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_secondary_roles_userId_core_users_id_fk": {
+ "name": "core_users_secondary_roles_userId_core_users_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_users_secondary_roles_roleId_core_roles_id_fk": {
+ "name": "core_users_secondary_roles_roleId_core_roles_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "core_users_secondary_roles_userId_roleId_pk": {
+ "name": "core_users_secondary_roles_userId_roleId_pk",
+ "columns": [
+ "userId",
+ "roleId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_sso": {
+ "name": "core_users_sso",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerId": {
+ "name": "providerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_sso_user_id_idx": {
+ "name": "core_users_sso_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_sso_userId_core_users_id_fk": {
+ "name": "core_users_sso_userId_core_users_id_fk",
+ "tableFrom": "core_users_sso",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_categories": {
+ "name": "blog_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_posts": {
+ "name": "blog_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "categoryId": {
+ "name": "categoryId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "blog_posts_categoryId_blog_categories_id_fk": {
+ "name": "blog_posts_categoryId_blog_categories_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "blog_categories",
+ "columnsFrom": [
+ "categoryId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "blog_posts_authorId_core_users_id_fk": {
+ "name": "blog_posts_authorId_core_users_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_articles": {
+ "name": "example_articles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "publishedAt": {
+ "name": "publishedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(160)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "excerpt": {
+ "name": "excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "featured": {
+ "name": "featured",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "author": {
+ "name": "author",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_articles_status_created_at_idx": {
+ "name": "example_articles_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_slug_key": {
+ "name": "example_articles_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_code_key": {
+ "name": "example_articles_code_key",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_author_idx": {
+ "name": "example_articles_author_idx",
+ "columns": [
+ {
+ "expression": "author",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_category_idx": {
+ "name": "example_articles_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_created_at_idx": {
+ "name": "example_articles_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_updated_at_idx": {
+ "name": "example_articles_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_status_published_at_idx": {
+ "name": "example_articles_status_published_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "publishedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "example_articles_author_core_users_id_fk": {
+ "name": "example_articles_author_core_users_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "author"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ },
+ "example_articles_category_example_categories_id_fk": {
+ "name": "example_articles_category_example_categories_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "example_categories",
+ "columnsFrom": [
+ "category"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_categories": {
+ "name": "example_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_categories_created_at_idx": {
+ "name": "example_categories_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_categories_updated_at_idx": {
+ "name": "example_categories_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/docs/migrations/meta/0026_snapshot.json b/apps/docs/migrations/meta/0026_snapshot.json
new file mode 100644
index 000000000..1890e22be
--- /dev/null
+++ b/apps/docs/migrations/meta/0026_snapshot.json
@@ -0,0 +1,2703 @@
+{
+ "id": "410ddb95-6db7-4923-96be-141fc9454cb6",
+ "prevId": "4f1c20aa-86f5-4d5b-9b4f-75101d57d8de",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.core_admin_permissions": {
+ "name": "core_admin_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_admin_permissions_role_id_idx": {
+ "name": "core_admin_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_permissions_user_id_idx": {
+ "name": "core_admin_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_permissions_roleId_core_roles_id_fk": {
+ "name": "core_admin_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_permissions_userId_core_users_id_fk": {
+ "name": "core_admin_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_sessions": {
+ "name": "core_admin_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_admin_sessions_token_idx": {
+ "name": "core_admin_sessions_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_sessions_user_id_idx": {
+ "name": "core_admin_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_sessions_userId_core_users_id_fk": {
+ "name": "core_admin_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_sessions_token_unique": {
+ "name": "core_admin_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_content_revisions": {
+ "name": "core_content_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contentTypeId": {
+ "name": "contentTypeId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "changedFields": {
+ "name": "changedFields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "actorType": {
+ "name": "actorType",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actorUserId": {
+ "name": "actorUserId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restoredFromRevisionId": {
+ "name": "restoredFromRevisionId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_content_revisions_item_version_unique": {
+ "name": "core_content_revisions_item_version_unique",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_plugin_id_idx": {
+ "name": "core_content_revisions_plugin_id_idx",
+ "columns": [
+ {
+ "expression": "pluginId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_actor_user_id_idx": {
+ "name": "core_content_revisions_actor_user_id_idx",
+ "columns": [
+ {
+ "expression": "actorUserId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_content_revisions_actorUserId_core_users_id_fk": {
+ "name": "core_content_revisions_actorUserId_core_users_id_fk",
+ "tableFrom": "core_content_revisions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "actorUserId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_cron": {
+ "name": "core_cron",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastRun": {
+ "name": "lastRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "module": {
+ "name": "module",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nextRun": {
+ "name": "nextRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_dashboard": {
+ "name": "core_admin_dashboard",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "widgets": {
+ "name": "widgets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_admin_dashboard_user_id_idx": {
+ "name": "core_admin_dashboard_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_dashboard_userId_core_users_id_fk": {
+ "name": "core_admin_dashboard_userId_core_users_id_fk",
+ "tableFrom": "core_admin_dashboard",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_dashboard_userId_unique": {
+ "name": "core_admin_dashboard_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_files": {
+ "name": "core_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mimeType": {
+ "name": "mimeType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_files_user_id_idx": {
+ "name": "core_files_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_files_userId_core_users_id_fk": {
+ "name": "core_files_userId_core_users_id_fk",
+ "tableFrom": "core_files",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_files_key_unique": {
+ "name": "core_files_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages": {
+ "name": "core_languages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time24": {
+ "name": "time24",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {
+ "core_languages_code_idx": {
+ "name": "core_languages_code_idx",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_languages_name_idx": {
+ "name": "core_languages_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_languages_code_unique": {
+ "name": "core_languages_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages_words": {
+ "name": "core_languages_words",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pluginCode": {
+ "name": "pluginCode",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tableName": {
+ "name": "tableName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "variable": {
+ "name": "variable",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_languages_words_lang_code_idx": {
+ "name": "core_languages_words_lang_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_languages_words_languageCode_core_languages_code_fk": {
+ "name": "core_languages_words_languageCode_core_languages_code_fk",
+ "tableFrom": "core_languages_words",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "languageCode"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_logs": {
+ "name": "core_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'localhost'"
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 500
+ },
+ "userId": {
+ "name": "userId",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "test123": {
+ "name": "test123",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_logs_userId_core_users_id_fk": {
+ "name": "core_logs_userId_core_users_id_fk",
+ "tableFrom": "core_logs",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_moderators_permissions": {
+ "name": "core_moderators_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_moderators_permissions_role_id_idx": {
+ "name": "core_moderators_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_moderators_permissions_user_id_idx": {
+ "name": "core_moderators_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_moderators_permissions_roleId_core_roles_id_fk": {
+ "name": "core_moderators_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_moderators_permissions_userId_core_users_id_fk": {
+ "name": "core_moderators_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_queue": {
+ "name": "core_queue",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queue": {
+ "name": "queue",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "maxAttempts": {
+ "name": "maxAttempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "availableAt": {
+ "name": "availableAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reservedAt": {
+ "name": "reservedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "core_queue_status_available_at_idx": {
+ "name": "core_queue_status_available_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "availableAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_roles": {
+ "name": "core_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "root": {
+ "name": "root",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "guest": {
+ "name": "guest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowUploadFiles": {
+ "name": "allowUploadFiles",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "totalMaxStorage": {
+ "name": "totalMaxStorage",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "maxStorageForSubmit": {
+ "name": "maxStorageForSubmit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_search_index": {
+ "name": "core_search_index",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemType": {
+ "name": "itemType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "search_vector": {
+ "name": "search_vector",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": true,
+ "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"
+ }
+ },
+ "containerType": {
+ "name": "containerType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "containerId": {
+ "name": "containerId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isPublic": {
+ "name": "isPublic",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "indexedAt": {
+ "name": "indexedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_search_index_search_vector_idx": {
+ "name": "core_search_index_search_vector_idx",
+ "columns": [
+ {
+ "expression": "search_vector",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "core_search_index_created_at_idx": {
+ "name": "core_search_index_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_author_id_idx": {
+ "name": "core_search_index_author_id_idx",
+ "columns": [
+ {
+ "expression": "authorId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_item_type_idx": {
+ "name": "core_search_index_item_type_idx",
+ "columns": [
+ {
+ "expression": "itemType",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_language_code_idx": {
+ "name": "core_search_index_language_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_is_public_idx": {
+ "name": "core_search_index_is_public_idx",
+ "columns": [
+ {
+ "expression": "isPublic",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_search_index_authorId_core_users_id_fk": {
+ "name": "core_search_index_authorId_core_users_id_fk",
+ "tableFrom": "core_search_index",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_search_index_item_unique": {
+ "name": "core_search_index_item_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "itemType",
+ "itemId",
+ "languageCode"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions": {
+ "name": "core_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_sessions_user_id_idx": {
+ "name": "core_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_sessions_userId_core_users_id_fk": {
+ "name": "core_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_token_unique": {
+ "name": "core_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions_known_devices": {
+ "name": "core_sessions_known_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "publicId": {
+ "name": "publicId",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_sessions_known_devices_ip_address_idx": {
+ "name": "core_sessions_known_devices_ip_address_idx",
+ "columns": [
+ {
+ "expression": "ipAddress",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_known_devices_publicId_unique": {
+ "name": "core_sessions_known_devices_publicId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "publicId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users": {
+ "name": "core_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "nameCode": {
+ "name": "nameCode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "newsletter": {
+ "name": "newsletter",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatarColor": {
+ "name": "avatarColor",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "core_users_name_code_idx": {
+ "name": "core_users_name_code_idx",
+ "columns": [
+ {
+ "expression": "nameCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_name_idx": {
+ "name": "core_users_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_email_idx": {
+ "name": "core_users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_roleId_core_roles_id_fk": {
+ "name": "core_users_roleId_core_roles_id_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "core_users_language_core_languages_code_fk": {
+ "name": "core_users_language_core_languages_code_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "language"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "set default",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_nameCode_unique": {
+ "name": "core_users_nameCode_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "nameCode"
+ ]
+ },
+ "core_users_name_unique": {
+ "name": "core_users_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ },
+ "core_users_email_unique": {
+ "name": "core_users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_confirm_emails": {
+ "name": "core_users_confirm_emails",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_confirm_emails_userId_core_users_id_fk": {
+ "name": "core_users_confirm_emails_userId_core_users_id_fk",
+ "tableFrom": "core_users_confirm_emails",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_confirm_emails_token_unique": {
+ "name": "core_users_confirm_emails_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_forgot_password": {
+ "name": "core_users_forgot_password",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_forgot_password_userId_core_users_id_fk": {
+ "name": "core_users_forgot_password_userId_core_users_id_fk",
+ "tableFrom": "core_users_forgot_password",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_forgot_password_userId_unique": {
+ "name": "core_users_forgot_password_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ },
+ "core_users_forgot_password_token_unique": {
+ "name": "core_users_forgot_password_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_secondary_roles": {
+ "name": "core_users_secondary_roles",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_secondary_roles_user_id_idx": {
+ "name": "core_users_secondary_roles_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_secondary_roles_role_id_idx": {
+ "name": "core_users_secondary_roles_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_secondary_roles_userId_core_users_id_fk": {
+ "name": "core_users_secondary_roles_userId_core_users_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_users_secondary_roles_roleId_core_roles_id_fk": {
+ "name": "core_users_secondary_roles_roleId_core_roles_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "core_users_secondary_roles_userId_roleId_pk": {
+ "name": "core_users_secondary_roles_userId_roleId_pk",
+ "columns": [
+ "userId",
+ "roleId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_sso": {
+ "name": "core_users_sso",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerId": {
+ "name": "providerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_sso_user_id_idx": {
+ "name": "core_users_sso_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_sso_userId_core_users_id_fk": {
+ "name": "core_users_sso_userId_core_users_id_fk",
+ "tableFrom": "core_users_sso",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_categories": {
+ "name": "blog_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_posts": {
+ "name": "blog_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "categoryId": {
+ "name": "categoryId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "blog_posts_categoryId_blog_categories_id_fk": {
+ "name": "blog_posts_categoryId_blog_categories_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "blog_categories",
+ "columnsFrom": [
+ "categoryId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "blog_posts_authorId_core_users_id_fk": {
+ "name": "blog_posts_authorId_core_users_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_articles": {
+ "name": "example_articles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "publishedAt": {
+ "name": "publishedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(160)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "excerpt": {
+ "name": "excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "featured": {
+ "name": "featured",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "author": {
+ "name": "author",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_articles_status_created_at_idx": {
+ "name": "example_articles_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_slug_key": {
+ "name": "example_articles_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_code_key": {
+ "name": "example_articles_code_key",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_author_idx": {
+ "name": "example_articles_author_idx",
+ "columns": [
+ {
+ "expression": "author",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_category_idx": {
+ "name": "example_articles_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_created_at_idx": {
+ "name": "example_articles_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_updated_at_idx": {
+ "name": "example_articles_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_status_published_at_idx": {
+ "name": "example_articles_status_published_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "publishedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "example_articles_author_core_users_id_fk": {
+ "name": "example_articles_author_core_users_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "author"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ },
+ "example_articles_category_example_categories_id_fk": {
+ "name": "example_articles_category_example_categories_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "example_categories",
+ "columnsFrom": [
+ "category"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_categories": {
+ "name": "example_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_categories_created_at_idx": {
+ "name": "example_categories_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_categories_updated_at_idx": {
+ "name": "example_categories_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/docs/migrations/meta/0027_snapshot.json b/apps/docs/migrations/meta/0027_snapshot.json
new file mode 100644
index 000000000..0f7a264b8
--- /dev/null
+++ b/apps/docs/migrations/meta/0027_snapshot.json
@@ -0,0 +1,2913 @@
+{
+ "id": "949a0b2c-84b1-43ba-83fa-2e2ab286905c",
+ "prevId": "410ddb95-6db7-4923-96be-141fc9454cb6",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.core_admin_permissions": {
+ "name": "core_admin_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_admin_permissions_role_id_idx": {
+ "name": "core_admin_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_permissions_user_id_idx": {
+ "name": "core_admin_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_permissions_roleId_core_roles_id_fk": {
+ "name": "core_admin_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_permissions_userId_core_users_id_fk": {
+ "name": "core_admin_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_sessions": {
+ "name": "core_admin_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_admin_sessions_token_idx": {
+ "name": "core_admin_sessions_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_sessions_user_id_idx": {
+ "name": "core_admin_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_sessions_userId_core_users_id_fk": {
+ "name": "core_admin_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_sessions_token_unique": {
+ "name": "core_admin_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_content_revisions": {
+ "name": "core_content_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contentTypeId": {
+ "name": "contentTypeId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "changedFields": {
+ "name": "changedFields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "actorType": {
+ "name": "actorType",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actorUserId": {
+ "name": "actorUserId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restoredFromRevisionId": {
+ "name": "restoredFromRevisionId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_content_revisions_item_version_unique": {
+ "name": "core_content_revisions_item_version_unique",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_plugin_id_idx": {
+ "name": "core_content_revisions_plugin_id_idx",
+ "columns": [
+ {
+ "expression": "pluginId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_actor_user_id_idx": {
+ "name": "core_content_revisions_actor_user_id_idx",
+ "columns": [
+ {
+ "expression": "actorUserId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_content_revisions_actorUserId_core_users_id_fk": {
+ "name": "core_content_revisions_actorUserId_core_users_id_fk",
+ "tableFrom": "core_content_revisions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "actorUserId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_content_schedules": {
+ "name": "core_content_schedules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contentTypeId": {
+ "name": "contentTypeId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheduledFor": {
+ "name": "scheduledFor",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "createdBy": {
+ "name": "createdBy",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "core_content_schedules_active_unique": {
+ "name": "core_content_schedules_active_unique",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_due_idx": {
+ "name": "core_content_schedules_due_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scheduledFor",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_item_idx": {
+ "name": "core_content_schedules_item_idx",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_plugin_id_idx": {
+ "name": "core_content_schedules_plugin_id_idx",
+ "columns": [
+ {
+ "expression": "pluginId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_created_by_idx": {
+ "name": "core_content_schedules_created_by_idx",
+ "columns": [
+ {
+ "expression": "createdBy",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_content_schedules_createdBy_core_users_id_fk": {
+ "name": "core_content_schedules_createdBy_core_users_id_fk",
+ "tableFrom": "core_content_schedules",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "createdBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_cron": {
+ "name": "core_cron",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastRun": {
+ "name": "lastRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "module": {
+ "name": "module",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nextRun": {
+ "name": "nextRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_dashboard": {
+ "name": "core_admin_dashboard",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "widgets": {
+ "name": "widgets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_admin_dashboard_user_id_idx": {
+ "name": "core_admin_dashboard_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_dashboard_userId_core_users_id_fk": {
+ "name": "core_admin_dashboard_userId_core_users_id_fk",
+ "tableFrom": "core_admin_dashboard",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_dashboard_userId_unique": {
+ "name": "core_admin_dashboard_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_files": {
+ "name": "core_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mimeType": {
+ "name": "mimeType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_files_user_id_idx": {
+ "name": "core_files_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_files_userId_core_users_id_fk": {
+ "name": "core_files_userId_core_users_id_fk",
+ "tableFrom": "core_files",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_files_key_unique": {
+ "name": "core_files_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages": {
+ "name": "core_languages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time24": {
+ "name": "time24",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {
+ "core_languages_code_idx": {
+ "name": "core_languages_code_idx",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_languages_name_idx": {
+ "name": "core_languages_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_languages_code_unique": {
+ "name": "core_languages_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages_words": {
+ "name": "core_languages_words",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pluginCode": {
+ "name": "pluginCode",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tableName": {
+ "name": "tableName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "variable": {
+ "name": "variable",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_languages_words_lang_code_idx": {
+ "name": "core_languages_words_lang_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_languages_words_languageCode_core_languages_code_fk": {
+ "name": "core_languages_words_languageCode_core_languages_code_fk",
+ "tableFrom": "core_languages_words",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "languageCode"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_logs": {
+ "name": "core_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'localhost'"
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 500
+ },
+ "userId": {
+ "name": "userId",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "test123": {
+ "name": "test123",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_logs_userId_core_users_id_fk": {
+ "name": "core_logs_userId_core_users_id_fk",
+ "tableFrom": "core_logs",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_moderators_permissions": {
+ "name": "core_moderators_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_moderators_permissions_role_id_idx": {
+ "name": "core_moderators_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_moderators_permissions_user_id_idx": {
+ "name": "core_moderators_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_moderators_permissions_roleId_core_roles_id_fk": {
+ "name": "core_moderators_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_moderators_permissions_userId_core_users_id_fk": {
+ "name": "core_moderators_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_queue": {
+ "name": "core_queue",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queue": {
+ "name": "queue",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "maxAttempts": {
+ "name": "maxAttempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "availableAt": {
+ "name": "availableAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reservedAt": {
+ "name": "reservedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "core_queue_status_available_at_idx": {
+ "name": "core_queue_status_available_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "availableAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_roles": {
+ "name": "core_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "root": {
+ "name": "root",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "guest": {
+ "name": "guest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowUploadFiles": {
+ "name": "allowUploadFiles",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "totalMaxStorage": {
+ "name": "totalMaxStorage",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "maxStorageForSubmit": {
+ "name": "maxStorageForSubmit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_search_index": {
+ "name": "core_search_index",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemType": {
+ "name": "itemType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "search_vector": {
+ "name": "search_vector",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": true,
+ "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"
+ }
+ },
+ "containerType": {
+ "name": "containerType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "containerId": {
+ "name": "containerId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isPublic": {
+ "name": "isPublic",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "indexedAt": {
+ "name": "indexedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_search_index_search_vector_idx": {
+ "name": "core_search_index_search_vector_idx",
+ "columns": [
+ {
+ "expression": "search_vector",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "core_search_index_created_at_idx": {
+ "name": "core_search_index_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_author_id_idx": {
+ "name": "core_search_index_author_id_idx",
+ "columns": [
+ {
+ "expression": "authorId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_item_type_idx": {
+ "name": "core_search_index_item_type_idx",
+ "columns": [
+ {
+ "expression": "itemType",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_language_code_idx": {
+ "name": "core_search_index_language_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_is_public_idx": {
+ "name": "core_search_index_is_public_idx",
+ "columns": [
+ {
+ "expression": "isPublic",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_search_index_authorId_core_users_id_fk": {
+ "name": "core_search_index_authorId_core_users_id_fk",
+ "tableFrom": "core_search_index",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_search_index_item_unique": {
+ "name": "core_search_index_item_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "itemType",
+ "itemId",
+ "languageCode"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions": {
+ "name": "core_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_sessions_user_id_idx": {
+ "name": "core_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_sessions_userId_core_users_id_fk": {
+ "name": "core_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_token_unique": {
+ "name": "core_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions_known_devices": {
+ "name": "core_sessions_known_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "publicId": {
+ "name": "publicId",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_sessions_known_devices_ip_address_idx": {
+ "name": "core_sessions_known_devices_ip_address_idx",
+ "columns": [
+ {
+ "expression": "ipAddress",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_known_devices_publicId_unique": {
+ "name": "core_sessions_known_devices_publicId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "publicId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users": {
+ "name": "core_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "nameCode": {
+ "name": "nameCode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "newsletter": {
+ "name": "newsletter",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatarColor": {
+ "name": "avatarColor",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "core_users_name_code_idx": {
+ "name": "core_users_name_code_idx",
+ "columns": [
+ {
+ "expression": "nameCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_name_idx": {
+ "name": "core_users_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_email_idx": {
+ "name": "core_users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_roleId_core_roles_id_fk": {
+ "name": "core_users_roleId_core_roles_id_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "core_users_language_core_languages_code_fk": {
+ "name": "core_users_language_core_languages_code_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "language"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "set default",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_nameCode_unique": {
+ "name": "core_users_nameCode_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "nameCode"
+ ]
+ },
+ "core_users_name_unique": {
+ "name": "core_users_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ },
+ "core_users_email_unique": {
+ "name": "core_users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_confirm_emails": {
+ "name": "core_users_confirm_emails",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_confirm_emails_userId_core_users_id_fk": {
+ "name": "core_users_confirm_emails_userId_core_users_id_fk",
+ "tableFrom": "core_users_confirm_emails",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_confirm_emails_token_unique": {
+ "name": "core_users_confirm_emails_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_forgot_password": {
+ "name": "core_users_forgot_password",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_forgot_password_userId_core_users_id_fk": {
+ "name": "core_users_forgot_password_userId_core_users_id_fk",
+ "tableFrom": "core_users_forgot_password",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_forgot_password_userId_unique": {
+ "name": "core_users_forgot_password_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ },
+ "core_users_forgot_password_token_unique": {
+ "name": "core_users_forgot_password_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_secondary_roles": {
+ "name": "core_users_secondary_roles",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_secondary_roles_user_id_idx": {
+ "name": "core_users_secondary_roles_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_secondary_roles_role_id_idx": {
+ "name": "core_users_secondary_roles_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_secondary_roles_userId_core_users_id_fk": {
+ "name": "core_users_secondary_roles_userId_core_users_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_users_secondary_roles_roleId_core_roles_id_fk": {
+ "name": "core_users_secondary_roles_roleId_core_roles_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "core_users_secondary_roles_userId_roleId_pk": {
+ "name": "core_users_secondary_roles_userId_roleId_pk",
+ "columns": [
+ "userId",
+ "roleId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_sso": {
+ "name": "core_users_sso",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerId": {
+ "name": "providerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_sso_user_id_idx": {
+ "name": "core_users_sso_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_sso_userId_core_users_id_fk": {
+ "name": "core_users_sso_userId_core_users_id_fk",
+ "tableFrom": "core_users_sso",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_categories": {
+ "name": "blog_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_posts": {
+ "name": "blog_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "categoryId": {
+ "name": "categoryId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "blog_posts_categoryId_blog_categories_id_fk": {
+ "name": "blog_posts_categoryId_blog_categories_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "blog_categories",
+ "columnsFrom": [
+ "categoryId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "blog_posts_authorId_core_users_id_fk": {
+ "name": "blog_posts_authorId_core_users_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_articles": {
+ "name": "example_articles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "publishedAt": {
+ "name": "publishedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(160)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "excerpt": {
+ "name": "excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "featured": {
+ "name": "featured",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "author": {
+ "name": "author",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_articles_status_created_at_idx": {
+ "name": "example_articles_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_slug_key": {
+ "name": "example_articles_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_code_key": {
+ "name": "example_articles_code_key",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_author_idx": {
+ "name": "example_articles_author_idx",
+ "columns": [
+ {
+ "expression": "author",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_category_idx": {
+ "name": "example_articles_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_created_at_idx": {
+ "name": "example_articles_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_updated_at_idx": {
+ "name": "example_articles_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_status_published_at_idx": {
+ "name": "example_articles_status_published_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "publishedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "example_articles_author_core_users_id_fk": {
+ "name": "example_articles_author_core_users_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "author"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ },
+ "example_articles_category_example_categories_id_fk": {
+ "name": "example_articles_category_example_categories_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "example_categories",
+ "columnsFrom": [
+ "category"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_categories": {
+ "name": "example_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_categories_created_at_idx": {
+ "name": "example_categories_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_categories_updated_at_idx": {
+ "name": "example_categories_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/docs/migrations/meta/0028_snapshot.json b/apps/docs/migrations/meta/0028_snapshot.json
new file mode 100644
index 000000000..7ccb924a1
--- /dev/null
+++ b/apps/docs/migrations/meta/0028_snapshot.json
@@ -0,0 +1,2919 @@
+{
+ "id": "ad292f66-b888-469c-84fc-5b2fb5dd0dcd",
+ "prevId": "949a0b2c-84b1-43ba-83fa-2e2ab286905c",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.core_admin_permissions": {
+ "name": "core_admin_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_admin_permissions_role_id_idx": {
+ "name": "core_admin_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_permissions_user_id_idx": {
+ "name": "core_admin_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_permissions_roleId_core_roles_id_fk": {
+ "name": "core_admin_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_permissions_userId_core_users_id_fk": {
+ "name": "core_admin_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_sessions": {
+ "name": "core_admin_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_admin_sessions_token_idx": {
+ "name": "core_admin_sessions_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_admin_sessions_user_id_idx": {
+ "name": "core_admin_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_sessions_userId_core_users_id_fk": {
+ "name": "core_admin_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_admin_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_sessions_token_unique": {
+ "name": "core_admin_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_content_revisions": {
+ "name": "core_content_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contentTypeId": {
+ "name": "contentTypeId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "changedFields": {
+ "name": "changedFields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "actorType": {
+ "name": "actorType",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actorUserId": {
+ "name": "actorUserId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restoredFromRevisionId": {
+ "name": "restoredFromRevisionId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_content_revisions_item_version_unique": {
+ "name": "core_content_revisions_item_version_unique",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_plugin_id_idx": {
+ "name": "core_content_revisions_plugin_id_idx",
+ "columns": [
+ {
+ "expression": "pluginId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_revisions_actor_user_id_idx": {
+ "name": "core_content_revisions_actor_user_id_idx",
+ "columns": [
+ {
+ "expression": "actorUserId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_content_revisions_actorUserId_core_users_id_fk": {
+ "name": "core_content_revisions_actorUserId_core_users_id_fk",
+ "tableFrom": "core_content_revisions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "actorUserId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_content_schedules": {
+ "name": "core_content_schedules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contentTypeId": {
+ "name": "contentTypeId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheduledFor": {
+ "name": "scheduledFor",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "createdBy": {
+ "name": "createdBy",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "effectsError": {
+ "name": "effectsError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "core_content_schedules_active_unique": {
+ "name": "core_content_schedules_active_unique",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_due_idx": {
+ "name": "core_content_schedules_due_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scheduledFor",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_item_idx": {
+ "name": "core_content_schedules_item_idx",
+ "columns": [
+ {
+ "expression": "contentTypeId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "itemId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_plugin_id_idx": {
+ "name": "core_content_schedules_plugin_id_idx",
+ "columns": [
+ {
+ "expression": "pluginId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_content_schedules_created_by_idx": {
+ "name": "core_content_schedules_created_by_idx",
+ "columns": [
+ {
+ "expression": "createdBy",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_content_schedules_createdBy_core_users_id_fk": {
+ "name": "core_content_schedules_createdBy_core_users_id_fk",
+ "tableFrom": "core_content_schedules",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "createdBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_cron": {
+ "name": "core_cron",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastRun": {
+ "name": "lastRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "module": {
+ "name": "module",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nextRun": {
+ "name": "nextRun",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_admin_dashboard": {
+ "name": "core_admin_dashboard",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "widgets": {
+ "name": "widgets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_admin_dashboard_user_id_idx": {
+ "name": "core_admin_dashboard_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_admin_dashboard_userId_core_users_id_fk": {
+ "name": "core_admin_dashboard_userId_core_users_id_fk",
+ "tableFrom": "core_admin_dashboard",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_admin_dashboard_userId_unique": {
+ "name": "core_admin_dashboard_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_files": {
+ "name": "core_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mimeType": {
+ "name": "mimeType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_files_user_id_idx": {
+ "name": "core_files_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_files_userId_core_users_id_fk": {
+ "name": "core_files_userId_core_users_id_fk",
+ "tableFrom": "core_files",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_files_key_unique": {
+ "name": "core_files_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages": {
+ "name": "core_languages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "time24": {
+ "name": "time24",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {
+ "core_languages_code_idx": {
+ "name": "core_languages_code_idx",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_languages_name_idx": {
+ "name": "core_languages_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_languages_code_unique": {
+ "name": "core_languages_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_languages_words": {
+ "name": "core_languages_words",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pluginCode": {
+ "name": "pluginCode",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tableName": {
+ "name": "tableName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "variable": {
+ "name": "variable",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_languages_words_lang_code_idx": {
+ "name": "core_languages_words_lang_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_languages_words_languageCode_core_languages_code_fk": {
+ "name": "core_languages_words_languageCode_core_languages_code_fk",
+ "tableFrom": "core_languages_words",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "languageCode"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_logs": {
+ "name": "core_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'localhost'"
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 500
+ },
+ "userId": {
+ "name": "userId",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "test123": {
+ "name": "test123",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_logs_userId_core_users_id_fk": {
+ "name": "core_logs_userId_core_users_id_fk",
+ "tableFrom": "core_logs",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_moderators_permissions": {
+ "name": "core_moderators_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "unrestricted": {
+ "name": "unrestricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "core_moderators_permissions_role_id_idx": {
+ "name": "core_moderators_permissions_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_moderators_permissions_user_id_idx": {
+ "name": "core_moderators_permissions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_moderators_permissions_roleId_core_roles_id_fk": {
+ "name": "core_moderators_permissions_roleId_core_roles_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_moderators_permissions_userId_core_users_id_fk": {
+ "name": "core_moderators_permissions_userId_core_users_id_fk",
+ "tableFrom": "core_moderators_permissions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_queue": {
+ "name": "core_queue",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queue": {
+ "name": "queue",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "maxAttempts": {
+ "name": "maxAttempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "availableAt": {
+ "name": "availableAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reservedAt": {
+ "name": "reservedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "core_queue_status_available_at_idx": {
+ "name": "core_queue_status_available_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "availableAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_roles": {
+ "name": "core_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "default": {
+ "name": "default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "root": {
+ "name": "root",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "guest": {
+ "name": "guest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowUploadFiles": {
+ "name": "allowUploadFiles",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "totalMaxStorage": {
+ "name": "totalMaxStorage",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "maxStorageForSubmit": {
+ "name": "maxStorageForSubmit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_search_index": {
+ "name": "core_search_index",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pluginId": {
+ "name": "pluginId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemType": {
+ "name": "itemType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "itemId": {
+ "name": "itemId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "languageCode": {
+ "name": "languageCode",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "search_vector": {
+ "name": "search_vector",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": true,
+ "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"
+ }
+ },
+ "containerType": {
+ "name": "containerType",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "containerId": {
+ "name": "containerId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isPublic": {
+ "name": "isPublic",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "indexedAt": {
+ "name": "indexedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_search_index_search_vector_idx": {
+ "name": "core_search_index_search_vector_idx",
+ "columns": [
+ {
+ "expression": "search_vector",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "core_search_index_created_at_idx": {
+ "name": "core_search_index_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_author_id_idx": {
+ "name": "core_search_index_author_id_idx",
+ "columns": [
+ {
+ "expression": "authorId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_item_type_idx": {
+ "name": "core_search_index_item_type_idx",
+ "columns": [
+ {
+ "expression": "itemType",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_language_code_idx": {
+ "name": "core_search_index_language_code_idx",
+ "columns": [
+ {
+ "expression": "languageCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_search_index_is_public_idx": {
+ "name": "core_search_index_is_public_idx",
+ "columns": [
+ {
+ "expression": "isPublic",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_search_index_authorId_core_users_id_fk": {
+ "name": "core_search_index_authorId_core_users_id_fk",
+ "tableFrom": "core_search_index",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_search_index_item_unique": {
+ "name": "core_search_index_item_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "itemType",
+ "itemId",
+ "languageCode"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions": {
+ "name": "core_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceId": {
+ "name": "deviceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "core_sessions_user_id_idx": {
+ "name": "core_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_sessions_userId_core_users_id_fk": {
+ "name": "core_sessions_userId_core_users_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_sessions_deviceId_core_sessions_known_devices_id_fk": {
+ "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk",
+ "tableFrom": "core_sessions",
+ "tableTo": "core_sessions_known_devices",
+ "columnsFrom": [
+ "deviceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_token_unique": {
+ "name": "core_sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_sessions_known_devices": {
+ "name": "core_sessions_known_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "publicId": {
+ "name": "publicId",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lastSeen": {
+ "name": "lastSeen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_sessions_known_devices_ip_address_idx": {
+ "name": "core_sessions_known_devices_ip_address_idx",
+ "columns": [
+ {
+ "expression": "ipAddress",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_sessions_known_devices_publicId_unique": {
+ "name": "core_sessions_known_devices_publicId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "publicId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users": {
+ "name": "core_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "nameCode": {
+ "name": "nameCode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "newsletter": {
+ "name": "newsletter",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatarColor": {
+ "name": "avatarColor",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "core_users_name_code_idx": {
+ "name": "core_users_name_code_idx",
+ "columns": [
+ {
+ "expression": "nameCode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_name_idx": {
+ "name": "core_users_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_email_idx": {
+ "name": "core_users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_roleId_core_roles_id_fk": {
+ "name": "core_users_roleId_core_roles_id_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "core_users_language_core_languages_code_fk": {
+ "name": "core_users_language_core_languages_code_fk",
+ "tableFrom": "core_users",
+ "tableTo": "core_languages",
+ "columnsFrom": [
+ "language"
+ ],
+ "columnsTo": [
+ "code"
+ ],
+ "onDelete": "set default",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_nameCode_unique": {
+ "name": "core_users_nameCode_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "nameCode"
+ ]
+ },
+ "core_users_name_unique": {
+ "name": "core_users_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ },
+ "core_users_email_unique": {
+ "name": "core_users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_confirm_emails": {
+ "name": "core_users_confirm_emails",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_confirm_emails_userId_core_users_id_fk": {
+ "name": "core_users_confirm_emails_userId_core_users_id_fk",
+ "tableFrom": "core_users_confirm_emails",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_confirm_emails_token_unique": {
+ "name": "core_users_confirm_emails_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_forgot_password": {
+ "name": "core_users_forgot_password",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "core_users_forgot_password_userId_core_users_id_fk": {
+ "name": "core_users_forgot_password_userId_core_users_id_fk",
+ "tableFrom": "core_users_forgot_password",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "core_users_forgot_password_userId_unique": {
+ "name": "core_users_forgot_password_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "userId"
+ ]
+ },
+ "core_users_forgot_password_token_unique": {
+ "name": "core_users_forgot_password_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_secondary_roles": {
+ "name": "core_users_secondary_roles",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roleId": {
+ "name": "roleId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_secondary_roles_user_id_idx": {
+ "name": "core_users_secondary_roles_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "core_users_secondary_roles_role_id_idx": {
+ "name": "core_users_secondary_roles_role_id_idx",
+ "columns": [
+ {
+ "expression": "roleId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_secondary_roles_userId_core_users_id_fk": {
+ "name": "core_users_secondary_roles_userId_core_users_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "core_users_secondary_roles_roleId_core_roles_id_fk": {
+ "name": "core_users_secondary_roles_roleId_core_roles_id_fk",
+ "tableFrom": "core_users_secondary_roles",
+ "tableTo": "core_roles",
+ "columnsFrom": [
+ "roleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "core_users_secondary_roles_userId_roleId_pk": {
+ "name": "core_users_secondary_roles_userId_roleId_pk",
+ "columns": [
+ "userId",
+ "roleId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.core_users_sso": {
+ "name": "core_users_sso",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerId": {
+ "name": "providerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "core_users_sso_user_id_idx": {
+ "name": "core_users_sso_user_id_idx",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "core_users_sso_userId_core_users_id_fk": {
+ "name": "core_users_sso_userId_core_users_id_fk",
+ "tableFrom": "core_users_sso",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_categories": {
+ "name": "blog_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.blog_posts": {
+ "name": "blog_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "categoryId": {
+ "name": "categoryId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "blog_posts_categoryId_blog_categories_id_fk": {
+ "name": "blog_posts_categoryId_blog_categories_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "blog_categories",
+ "columnsFrom": [
+ "categoryId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "blog_posts_authorId_core_users_id_fk": {
+ "name": "blog_posts_authorId_core_users_id_fk",
+ "tableFrom": "blog_posts",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "authorId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_articles": {
+ "name": "example_articles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "publishedAt": {
+ "name": "publishedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(160)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "excerpt": {
+ "name": "excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "featured": {
+ "name": "featured",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "author": {
+ "name": "author",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_articles_status_created_at_idx": {
+ "name": "example_articles_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_slug_key": {
+ "name": "example_articles_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_code_key": {
+ "name": "example_articles_code_key",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_author_idx": {
+ "name": "example_articles_author_idx",
+ "columns": [
+ {
+ "expression": "author",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_category_idx": {
+ "name": "example_articles_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_created_at_idx": {
+ "name": "example_articles_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_updated_at_idx": {
+ "name": "example_articles_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_status_published_at_idx": {
+ "name": "example_articles_status_published_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "publishedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "example_articles_author_core_users_id_fk": {
+ "name": "example_articles_author_core_users_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "core_users",
+ "columnsFrom": [
+ "author"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "cascade"
+ },
+ "example_articles_category_example_categories_id_fk": {
+ "name": "example_articles_category_example_categories_id_fk",
+ "tableFrom": "example_articles",
+ "tableTo": "example_categories",
+ "columnsFrom": [
+ "category"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.example_categories": {
+ "name": "example_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "example_categories_created_at_idx": {
+ "name": "example_categories_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_categories_updated_at_idx": {
+ "name": "example_categories_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json
index 74d9a35a7..b9fbeb52a 100644
--- a/apps/docs/migrations/meta/_journal.json
+++ b/apps/docs/migrations/meta/_journal.json
@@ -176,6 +176,34 @@
"when": 1785764469085,
"tag": "0024_add_example_article_slug",
"breakpoints": true
+ },
+ {
+ "idx": 25,
+ "version": "7",
+ "when": 1785955595563,
+ "tag": "0025_add_content_revisions",
+ "breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "7",
+ "when": 1785957233989,
+ "tag": "0026_add_example_article_editorial",
+ "breakpoints": true
+ },
+ {
+ "idx": 27,
+ "version": "7",
+ "when": 1786018313984,
+ "tag": "0027_add_content_schedules",
+ "breakpoints": true
+ },
+ {
+ "idx": 28,
+ "version": "7",
+ "when": 1786024625069,
+ "tag": "0028_add_content_schedule_effects_error",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/apps/docs/src/app/api/vitnode/content/revalidate/route.ts b/apps/docs/src/app/api/vitnode/content/revalidate/route.ts
new file mode 100644
index 000000000..7494f6bc1
--- /dev/null
+++ b/apps/docs/src/app/api/vitnode/content/revalidate/route.ts
@@ -0,0 +1,12 @@
+/**
+ * The web-side half of the Content Engine's background cache bridge.
+ *
+ * Needed because the API process cannot call `next/cache`: in a split
+ * deployment it is plain Node, and even inside this app the queue runs in a
+ * Route Handler where `updateTag` is unavailable. When a scheduled publish
+ * makes a record public, this is what expires the tags.
+ *
+ * Authorized with `CRON_SECRET` and a timestamp window. The worst a valid
+ * request can do is expire a cache tag.
+ */
+export { POST } from "@vitnode/core/content/next/revalidate-route";
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts
new file mode 100644
index 000000000..2d534250f
--- /dev/null
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts
@@ -0,0 +1,12 @@
+/**
+ * The web-side half of the Content Engine's background cache bridge.
+ *
+ * Needed because the API process cannot call `next/cache`. When a scheduled
+ * publish makes a record public, this is what expires the tags so the page goes
+ * live without waiting for the cache to age out.
+ *
+ * Authorized with `CRON_SECRET` and a timestamp window. The worst a valid
+ * request can do is expire a cache tag. Delete this file only if you never use
+ * [scheduled publishing](https://vitnode.com/docs/dev/content-engine/scheduling).
+ */
+export { POST } from "@vitnode/core/content/next/revalidate-route";
diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json
index 1e6ffb954..a2dfe6a95 100644
--- a/packages/vitnode/package.json
+++ b/packages/vitnode/package.json
@@ -96,6 +96,11 @@
"types": "./dist/src/content/next/index.d.ts",
"default": "./dist/src/content/next/index.js"
},
+ "./content/next/revalidate-route": {
+ "import": "./dist/src/content/next/revalidate-route.server.js",
+ "types": "./dist/src/content/next/revalidate-route.server.d.ts",
+ "default": "./dist/src/content/next/revalidate-route.server.js"
+ },
"./api/config": {
"import": "./dist/src/api/config.js",
"types": "./dist/src/api/config.d.ts",
diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts
index 90b1683ef..90bfc81c6 100644
--- a/packages/vitnode/src/api/config.ts
+++ b/packages/vitnode/src/api/config.ts
@@ -83,6 +83,7 @@ export function VitNodeAPI({
authorization: vitNodeApiConfig.authorization,
dbProvider: vitNodeApiConfig.dbProvider,
captcha: vitNodeApiConfig.captcha,
+ content: vitNodeApiConfig.content,
cron: vitNodeApiConfig.cron,
events: vitNodeApiConfig.events,
search: vitNodeApiConfig.search,
diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts
index f033df311..00fcd8246 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 { AnyContentModel } from "@/content/server/model";
import type { AnyContentTypeDefinition } from "@/content/types";
import type { SearchIndexer } from "../models/search";
@@ -19,6 +20,16 @@ export interface BaseBuildModuleReturn<
M extends string = string,
Routes extends Route[] = Route
[],
> {
+ /**
+ * The models behind those content types - table, columns, schemas and
+ * services, not just the definition.
+ *
+ * Collected recursively like `contentTypes`, and exposed on the request
+ * context so background work can find the model for a content type id. The
+ * scheduled-publication queue task is the reason it exists: it runs in a cron
+ * request that has no idea which plugin owns the record it is publishing.
+ */
+ contentModels?: AnyContentModel[];
/**
* Content types whose CRUD routes this module serves. Unlike `events` and
* `cronJobs`, these are collected recursively by `buildApiPlugin`, so a
@@ -57,6 +68,7 @@ export function buildModule<
pluginId,
name,
modules,
+ contentModels,
contentTypes,
cronJobs = [],
events = [],
@@ -64,6 +76,7 @@ export function buildModule<
searchIndexers,
webSockets = [],
}: {
+ contentModels?: AnyContentModel[];
contentTypes?: AnyContentTypeDefinition[];
cronJobs?: BuildCronReturn[];
events?: BuildEventListenerReturn[];
@@ -95,6 +108,7 @@ export function buildModule<
hono,
name,
modules,
+ contentModels,
contentTypes,
cronJobs,
events,
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index 29306c788..c559072e5 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 { RegisteredContentType } from "@/content/registry";
+import type { AnyContentModel } from "@/content/server/model";
import type { AnyContentTypeDefinition } from "@/content/types";
import type { LocaleMessagesMap } from "@/lib/i18n/types";
@@ -21,6 +22,7 @@ import { validateSearchIndexers } from "../models/search";
import { checkPluginId } from "./check-plugin-id";
export interface BuildPluginApiReturn {
+ contentModels?: AnyContentModel[];
contentTypes?: AnyContentTypeDefinition[];
cronJobs?: Omit[];
events?: Omit[];
@@ -56,6 +58,7 @@ export function buildApiPlugin({
checkPluginId(pluginId);
const hono = new OpenAPIHono();
+ const contentModels: AnyContentModel[] = [];
const contentTypes: AnyContentTypeDefinition[] = [];
const cronJobs: BuildPluginApiReturn["cronJobs"] = [];
const events: BuildPluginApiReturn["events"] = [];
@@ -65,6 +68,7 @@ export function buildApiPlugin
({
modules.forEach(handler => {
hono.route(`/${handler.name}`, handler.hono);
+ contentModels.push(...collectContentModels(handler));
contentTypes.push(...collectContentTypes(handler));
indexers.push(...collectSearchIndexers(handler));
@@ -95,6 +99,7 @@ export function buildApiPlugin
({
pluginId,
messages,
hono,
+ contentModels,
contentTypes: registered.map(entry => entry.definition),
cronJobs,
events,
@@ -122,6 +127,16 @@ function collectContentTypes(
];
}
+/** Same walk as {@link collectContentTypes}, and for the same reason. */
+function collectContentModels(
+ module: BaseBuildModuleReturn,
+): AnyContentModel[] {
+ return [
+ ...(module.contentModels ?? []),
+ ...(module.modules ?? []).flatMap(collectContentModels),
+ ];
+}
+
function collectSearchIndexers(module: BaseBuildModuleReturn): SearchIndexer[] {
return [
...(module.searchIndexers ?? []),
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 5c5a366a3..f1efc4cd4 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -4,6 +4,7 @@ import type { Redis } from "ioredis";
import { HTTPException } from "hono/http-exception";
import type { RegisteredContentType } from "@/content/registry";
+import type { RegisteredContentModel } from "@/content/server/model";
import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types";
import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config";
import type { VitNodeRealtime } from "@/ws/registry";
@@ -21,6 +22,7 @@ import { SessionModel } from "@/api/models/session";
import { SessionAdminModel } from "@/api/models/session-admin";
import { StorageModel } from "@/api/models/storage";
import { validateContentTypes } from "@/content/registry";
+import { assertContentPreviewConfig } from "@/content/server/preview-config";
import { CONFIG } from "@/lib/config";
import { collectLocaleCodes } from "@/lib/i18n/load-messages";
import { buildApiMessagesSources } from "@/lib/i18n/sources";
@@ -85,6 +87,19 @@ export interface EnvVariablesVitNode {
ssoAdapters: SSOApiPlugin[];
};
captcha?: Pick["captcha"];
+ /**
+ * Every registered content type's *model*, with the plugin that owns it.
+ *
+ * Background work has only a content type id to go on - a queue handler
+ * runs in a cron request with no plugin context at all - so the lookup from
+ * id to table, service and owner has to live somewhere it can reach.
+ */
+ contentModels: RegisteredContentModel[];
+ /** Signs content preview links. Flagged in the admin integrations panel
+ * while it is still the well-known default. */
+ contentPreviewSecret?: string;
+ /** Web origins the background cache bridge posts to. */
+ contentRevalidateOrigins?: string[];
contentTypes: RegisteredContentType[];
cron: (BuildCronReturn & { module: string; pluginId: string })[];
cronSecret?: string;
@@ -142,6 +157,7 @@ export interface EnvVariablesVitNode {
export const globalMiddleware = ({
ai,
authorization,
+ content,
metadata,
email,
dbProvider,
@@ -158,6 +174,7 @@ export const globalMiddleware = ({
| "ai"
| "authorization"
| "captcha"
+ | "content"
| "cron"
| "dbProvider"
| "email"
@@ -237,6 +254,24 @@ export const globalMiddleware = ({
),
);
+ // Once, here, because "does anything have preview enabled" is only answerable
+ // after every plugin's content types are in. Throws in production rather than
+ // booting an install whose preview links anyone could forge.
+ assertContentPreviewConfig({
+ contentTypes: contentTypesMetadata,
+ secret: process.env.CONTENT_PREVIEW_SECRET,
+ });
+
+ // Not validated: a model carries the definition that `contentTypesMetadata`
+ // already checked, so a second pass would only repeat the same errors.
+ const contentModelsMetadata: RegisteredContentModel[] = plugins.flatMap(
+ plugin =>
+ (plugin.contentModels ?? []).map(model => ({
+ model,
+ pluginId: plugin.pluginId,
+ })),
+ );
+
const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map(
plugin => ({
pluginId: plugin.pluginId,
@@ -317,6 +352,7 @@ export const globalMiddleware = ({
cookieSecure: authorization?.cookieSecure ?? true,
},
captcha,
+ contentPreviewSecret: CONFIG.contentPreviewSecret,
cronSecret: CONFIG.cronJobSecret,
hasCronAdapter: !!cron,
plugins: pluginsMetadata,
@@ -324,6 +360,8 @@ export const globalMiddleware = ({
queue: queueMetadata,
webSockets: webSocketsMetadata,
permissionStaff: permissionStaffMetadata,
+ contentModels: contentModelsMetadata,
+ contentRevalidateOrigins: content?.revalidateOrigins,
contentTypes: contentTypesMetadata,
});
diff --git a/packages/vitnode/src/api/models/events.test.ts b/packages/vitnode/src/api/models/events.test.ts
index f9b834a32..95cbee53e 100644
--- a/packages/vitnode/src/api/models/events.test.ts
+++ b/packages/vitnode/src/api/models/events.test.ts
@@ -210,6 +210,42 @@ describe("EventsModel.emit envelope", () => {
expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog");
});
+ it("an explicit owner wins over the context plugin", async () => {
+ // The queue case: core owns the handler, so the context says core, but the
+ // domain event belongs to whoever owns the thing it happened to.
+ const { adapter, publish } = captureEnvelope();
+ const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/core" } });
+
+ await new EventsModel(ctx).emit("user.created", PAYLOAD, {
+ pluginId: "@vitnode/example",
+ });
+
+ expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/example");
+ });
+
+ it("an omitted override changes nothing for existing callers", async () => {
+ const { adapter, publish } = captureEnvelope();
+ const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/blog" } });
+
+ await new EventsModel(ctx).emit("user.created", PAYLOAD, {});
+
+ expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog");
+ });
+
+ it("does not impersonate the plugin on the shared context", async () => {
+ // Overriding by swapping `c.get("plugin")` would change the logger, the
+ // permission checks and every other model on the request to fix one field.
+ const { adapter, publish } = captureEnvelope();
+ const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/core" } });
+
+ await new EventsModel(ctx).emit("user.created", PAYLOAD, {
+ pluginId: "@vitnode/example",
+ });
+
+ expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/example");
+ expect(ctx.get("plugin").id).toBe("@vitnode/core");
+ });
+
it("derives the actor: admin wins over user, then user, then system", async () => {
const { adapter, publish } = captureEnvelope();
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 4b5426581..96ad07aa2 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -100,6 +100,26 @@ export interface EventsApiPlugin {
publish: (c: Context, envelope: EventEnvelope) => Promise;
}
+export interface EventEmitOptions {
+ /**
+ * Who owns the *domain event*, when that is not the plugin handling the
+ * request.
+ *
+ * Ownership normally comes from `c.get("plugin")`, which is right for a route:
+ * whoever handled the request emitted the event. It is wrong for anything that
+ * runs on someone else's behalf. A queue handler is the clear case - core owns
+ * the handler, so the context says `@vitnode/core`, but a scheduled
+ * `content.example.article.published` is the example plugin's event and always
+ * was.
+ *
+ * Pass it explicitly rather than swapping `c.get("plugin")` for the duration.
+ * The context is shared with the logger, the permission checks and every other
+ * model on the request; impersonating a plugin inside it would change all of
+ * them to fix one field.
+ */
+ pluginId?: string;
+}
+
export class EventsModel {
constructor(c: Context) {
this.c = c;
@@ -117,10 +137,17 @@ export class EventsModel {
* AFTER the writes the event describes have committed - after your awaited
* inserts/updates, and after any enclosing `db.transaction` callback has
* returned.
+ *
+ * **Not throwing is the contract, not an oversight.** An interactive mutation
+ * has already committed by the time this runs, and a listener that fell over
+ * is not a reason to tell the person their save failed. A caller that *does*
+ * need delivery to be retried - the scheduled-effects task is the one in
+ * core - reads `failures` and decides for itself.
*/
async emit(
name: K,
payload: VitNodeEvents[K],
+ options?: EventEmitOptions,
): Promise {
const admin = this.c.get("admin");
const user = this.c.get("user");
@@ -129,7 +156,8 @@ export class EventsModel {
name,
payload,
emittedAt: new Date(),
- pluginId: this.c.get("plugin")?.id ?? "@vitnode/core",
+ pluginId:
+ options?.pluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core",
actor: admin
? { type: "admin", id: admin.user.id }
: user
diff --git a/packages/vitnode/src/api/models/queue.test.ts b/packages/vitnode/src/api/models/queue.test.ts
index c0cd10117..382f9eb89 100644
--- a/packages/vitnode/src/api/models/queue.test.ts
+++ b/packages/vitnode/src/api/models/queue.test.ts
@@ -1,86 +1,126 @@
+// @vitest-environment node
import type { Context } from "hono";
import { describe, expect, it, vi } from "vitest";
import { QueueModel } from "./queue";
-const makeCtx = (
- overrides: {
- plugin?: { id: string };
- queue?: { maxAttempts?: number; name: string; pluginId: string }[];
- } = {},
-): {
- ctx: Context;
- values: ReturnType;
-} => {
- const values = vi.fn().mockReturnValue({
- returning: vi.fn().mockResolvedValue([{ id: 1 }]),
+/** Records what was inserted, and through which handle. */
+const harness = ({ plugin }: { plugin?: string } = {}) => {
+ const inserts: { handle: string; values: Record }[] = [];
+
+ const handle = (name: string) => ({
+ insert: () => ({
+ values: (values: Record) => ({
+ returning: async () => {
+ inserts.push({ handle: name, values });
+
+ return await Promise.resolve([{ id: 1 }]);
+ },
+ }),
+ }),
});
- const store: Record = {
- db: { insert: vi.fn().mockReturnValue({ values }) },
- core: { queue: overrides.queue ?? [] },
- plugin: overrides.plugin,
- };
-
- return {
- ctx: { get: (k: string) => store[k] } as unknown as Context,
- values,
- };
+
+ const c = {
+ get: (key: string) =>
+ key === "db"
+ ? handle("request")
+ : key === "core"
+ ? { queue: [{ maxAttempts: 7, name: "known", pluginId: plugin }] }
+ : key === "plugin"
+ ? plugin
+ ? { id: plugin }
+ : undefined
+ : undefined,
+ } as unknown as Context;
+
+ return { c, inserts, tx: handle("transaction") };
};
describe("QueueModel.dispatch", () => {
- it("uses the explicit maxAttempts when provided", async () => {
- const { ctx, values } = makeCtx({
- queue: [{ name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }],
- });
+ it("stamps the requesting plugin by default", async () => {
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
- await new QueueModel(ctx).dispatch({ name: "job", maxAttempts: 7 });
+ await new QueueModel(c).dispatch({ name: "do-something" });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 7 });
+ expect(inserts[0].values.pluginId).toBe("@vitnode/example");
});
- it("falls back to the registered task maxAttempts", async () => {
- const { ctx, values } = makeCtx({
- queue: [{ name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }],
- });
+ it("falls back to core when no plugin is handling the request", async () => {
+ const { c, inserts } = harness();
- await new QueueModel(ctx).dispatch({ name: "job" });
+ await new QueueModel(c).dispatch({ name: "do-something" });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 5 });
+ expect(inserts[0].values.pluginId).toBe("@vitnode/core");
});
- it("defaults to 3 when the registered task has no maxAttempts", async () => {
- const { ctx, values } = makeCtx({
- queue: [{ name: "job", pluginId: "@vitnode/core" }],
- });
+ it("stamps an explicit plugin instead", async () => {
+ // The case that makes scheduled publication work at all: a plugin's route
+ // dispatches a task core owns. The worker resolves handlers by
+ // `${pluginId}:${name}`, so the plugin's own id would leave the row
+ // unclaimable forever.
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
- await new QueueModel(ctx).dispatch({ name: "job" });
+ await new QueueModel(c).dispatch({
+ name: "content-schedule",
+ pluginId: "@vitnode/core",
+ });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 });
+ expect(inserts[0].values.pluginId).toBe("@vitnode/core");
});
- it("defaults to 3 when the task is not registered", async () => {
- const { ctx, values } = makeCtx({ queue: [] });
+ it("uses the request handle when no transaction is given", async () => {
+ const { c, inserts } = harness();
- await new QueueModel(ctx).dispatch({ name: "job" });
+ await new QueueModel(c).dispatch({ name: "do-something" });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 });
+ expect(inserts[0].handle).toBe("request");
});
- it("scopes the task lookup by pluginId", async () => {
- const { ctx, values } = makeCtx({
- plugin: { id: "@vitnode/blog" },
- queue: [
- { name: "job", pluginId: "@vitnode/core", maxAttempts: 5 },
- { name: "job", pluginId: "@vitnode/blog", maxAttempts: 9 },
- ],
+ it("joins a transaction when one is given", async () => {
+ // Without this the queue row can commit while the row it points at rolls
+ // back, and the task wakes up to find nothing there.
+ const { c, inserts, tx } = harness();
+
+ await new QueueModel(c).dispatch({
+ name: "do-something",
+ tx: tx as never,
});
- await new QueueModel(ctx).dispatch({ name: "job" });
+ expect(inserts[0].handle).toBe("transaction");
+ });
+
+ it("still reads the registered task's maxAttempts", async () => {
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
- expect(values.mock.calls[0][0]).toMatchObject({
- pluginId: "@vitnode/blog",
- maxAttempts: 9,
+ await new QueueModel(c).dispatch({ name: "known" });
+
+ expect(inserts[0].values.maxAttempts).toBe(7);
+ });
+
+ it("looks the task up under the plugin it is dispatched for", async () => {
+ // `known` is registered under `@vitnode/example`, so dispatching it as core
+ // finds no registration and falls back to the default.
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
+
+ await new QueueModel(c).dispatch({
+ name: "known",
+ pluginId: "@vitnode/core",
});
+
+ expect(inserts[0].values.maxAttempts).toBe(3);
+ });
+
+ it("defaults availableAt to now, so a task runs on the next tick", async () => {
+ const now = new Date("2026-08-05T10:00:00.000Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+
+ const { c, inserts } = harness();
+ await new QueueModel(c).dispatch({ name: "do-something" });
+
+ expect(inserts[0].values.availableAt).toEqual(now);
+
+ vi.useRealTimers();
});
});
diff --git a/packages/vitnode/src/api/models/queue.ts b/packages/vitnode/src/api/models/queue.ts
index 38f66309f..7cd68b19c 100644
--- a/packages/vitnode/src/api/models/queue.ts
+++ b/packages/vitnode/src/api/models/queue.ts
@@ -7,8 +7,26 @@ export interface QueueDispatchArgs {
maxAttempts?: number;
name: string;
payload?: Record;
+ /**
+ * Who owns the handler, when that is not the plugin handling the request.
+ *
+ * The worker resolves a handler by `` `${pluginId}:${name}` ``, so a task
+ * registered by core but dispatched from a plugin's route needs to say so -
+ * otherwise the row is stamped with the plugin's id and nothing will ever
+ * claim it. Defaults to the requesting plugin, which is right for the
+ * ordinary case where a plugin dispatches its own task.
+ */
+ pluginId?: string;
priority?: number;
queue?: string;
+ /**
+ * Join an existing transaction instead of using the request handle.
+ *
+ * Needed whenever the row that the task refers to is written in the same
+ * unit of work: without it, the queue row can commit while the row it points
+ * at rolls back, and the task wakes up to find nothing there.
+ */
+ tx?: Omit;
}
/**
@@ -27,19 +45,21 @@ export class QueueModel {
async dispatch({
name,
payload = {},
+ pluginId: explicitPluginId,
queue = "default",
priority = 0,
maxAttempts,
availableAt,
+ tx,
}: QueueDispatchArgs): Promise<{ id: number }> {
- const pluginId = this.c.get("plugin")?.id ?? "@vitnode/core";
+ const pluginId =
+ explicitPluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core";
const registeredTask = this.c
.get("core")
.queue.find(task => task.pluginId === pluginId && task.name === name);
- const [row] = await this.c
- .get("db")
+ const [row] = await (tx ?? this.c.get("db"))
.insert(core_queue)
.values({
pluginId,
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts
index 000a41381..bb760c958 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts
@@ -6,7 +6,10 @@ import { core_cron } from "@/database/cron";
import { core_queue } from "@/database/queue";
import { getQueueStatus } from "@/lib/api/get-queue-status";
import { isCronStale } from "@/lib/api/is-cron-stale";
-import { INSECURE_DEFAULT_CRON_SECRET } from "@/lib/config";
+import {
+ INSECURE_DEFAULT_CRON_SECRET,
+ isSecureContentPreviewSecret,
+} from "@/lib/config";
import { isRealtimePubSubEnabled, isWebSocketEnabled } from "@/ws/registry";
import { buildRoute } from "../../../../lib/route";
@@ -38,6 +41,18 @@ export const integrationsDebugAdminRoute = buildRoute({
.enum(["cloudflare_turnstile", "recaptcha_v3"])
.nullable(),
}),
+ contentPreview: z.object({
+ // `true` when at least one content type has
+ // `editorial.preview.enabled`, i.e. the preview routes exist.
+ active: z.boolean(),
+ // How many content types can mint preview links.
+ contentTypes: z.number(),
+ // `false` when `CONTENT_PREVIEW_SECRET` is missing, left at its
+ // well-known default, or too short to be a signing key. Preview
+ // does not merely warn in that state - it refuses to serve, and
+ // a production boot fails outright.
+ secure: z.boolean(),
+ }),
cron: z.object({
// `true` when a cron adapter is configured, i.e. an in-process
// scheduler is running the registered jobs automatically.
@@ -133,6 +148,9 @@ export const integrationsDebugAdminRoute = buildRoute({
cronActivity?.lastActivity ? new Date(cronActivity.lastActivity) : null,
);
const cronActive = core.hasCronAdapter;
+ const previewContentTypes = core.contentTypes.filter(
+ entry => entry.definition.editorial.preview.enabled,
+ ).length;
const queueStatus = getQueueStatus({
cronActive,
cronStale,
@@ -152,6 +170,13 @@ export const integrationsDebugAdminRoute = buildRoute({
active: !!(captcha?.secretKey && captcha.siteKey),
type: captcha?.type ?? null,
},
+ contentPreview: {
+ active: previewContentTypes > 0,
+ contentTypes: previewContentTypes,
+ // The same predicate the routes fail closed on, so the panel and the
+ // behaviour cannot disagree about what "secure" means.
+ secure: isSecureContentPreviewSecret(core.contentPreviewSecret),
+ },
cron: {
active: cronActive,
jobs: core.cron.length,
diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts
new file mode 100644
index 000000000..682152133
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/content.module.ts
@@ -0,0 +1,33 @@
+import { buildModule } from "@/api/lib/module";
+import { CONFIG_PLUGIN } from "@/config";
+
+import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron";
+import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task";
+import { contentScheduleQueueTask } from "./tasks/content-schedule.task";
+
+/**
+ * Core's own Content Engine module: the background half.
+ *
+ * It serves no routes. It exists because `queueTasks` and `cronJobs` are
+ * collected from **top-level** modules only, while `buildContentAdminModule` is
+ * nested inside a plugin's `admin` module - so a task registered there would be
+ * silently dropped, with no error and no handler.
+ *
+ * One task for every schedulable content type in the install, rather than one
+ * per type. The handler resolves the model from `c.get("core").contentModels`,
+ * so adding a content type adds no task, no name to collide with, and no
+ * registration to forget.
+ *
+ * Two tasks rather than one, because a scheduled publication is two units of
+ * work with two different failure meanings: `content-schedule` moves the
+ * database and either commits or does not, and `content-schedule-effects`
+ * announces what committed and can be retried on its own without ever
+ * republishing.
+ */
+export const contentModule = buildModule({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ name: "content",
+ routes: [],
+ cronJobs: [contentEditorialCleanupCron],
+ queueTasks: [contentScheduleQueueTask, contentScheduleEffectsQueueTask],
+});
diff --git a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts
new file mode 100644
index 000000000..3130c3358
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts
@@ -0,0 +1,59 @@
+import { buildCron } from "@/api/lib/cron";
+import {
+ CONTENT_REVISION_MAX_RETENTION,
+ CONTENT_SCHEDULE_RETENTION_DAYS,
+} from "@/content/const";
+import { pruneContentRevisions } from "@/content/server/revisions-model";
+import { pruneContentSchedules } from "@/content/server/schedules-model";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Sweeps up editorial rows that no longer describe anything.
+ *
+ * Revision retention is enforced inline, in the same transaction as the write,
+ * so this is **not** the thing that keeps the table bounded on a healthy
+ * install - an install with no cron adapter must not grow forever, and it does
+ * not. What this handles is the case inline pruning structurally cannot: rows
+ * whose content type stopped existing, so nothing will ever write to them again
+ * and trigger a prune.
+ *
+ * Daily rather than hourly. Nothing here is urgent, and a plugin removed at
+ * lunchtime does not need its history gone by teatime.
+ */
+export const contentEditorialCleanupCron = buildCron({
+ name: "content-editorial-cleanup",
+ description:
+ "Remove revisions and schedules for content types that are no longer registered, and settled schedules past their retention window.",
+ // 03:20 daily, off the hour so it does not pile onto every other daily job.
+ schedule: "20 3 * * *",
+ handler: async c => {
+ const known = c
+ .get("core")
+ .contentTypes.filter(entry => entry.definition.editorial.enabled)
+ .map(entry => entry.definition.id);
+
+ const schedules = await pruneContentSchedules({
+ db: c.get("db"),
+ knownContentTypeIds: known,
+ olderThan: new Date(
+ Date.now() - CONTENT_SCHEDULE_RETENTION_DAYS * DAY_MS,
+ ),
+ });
+
+ const revisions = await pruneContentRevisions({
+ db: c.get("db"),
+ knownContentTypeIds: known,
+ });
+
+ if (schedules.orphaned + revisions.orphaned === 0) return;
+
+ // Worth saying out loud: an unexpected number here usually means a plugin
+ // id or a content type id was renamed without the documented UPDATE.
+ await c
+ .get("log")
+ .debug(
+ `[content-editorial-cleanup] removed ${revisions.orphaned} orphaned revisions and ${schedules.orphaned} orphaned schedules (${schedules.settled} settled schedules aged out; revision retention stays capped at ${CONTENT_REVISION_MAX_RETENTION} per record).`,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
new file mode 100644
index 000000000..7f2b98ac8
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
@@ -0,0 +1,402 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { testEditorialPostContentType } from "@/tests/content-fixtures";
+
+const claimContentSchedule = vi.fn();
+const settleContentSchedule = vi.fn();
+
+vi.mock("@/content/server/schedules-model", () => ({
+ claimContentSchedule: (...args: unknown[]) => claimContentSchedule(...args),
+ settleContentSchedule: (...args: unknown[]) => settleContentSchedule(...args),
+}));
+
+const { executeContentSchedule } = await import("./execute-content-schedule");
+
+const PLUGIN_ID = "@vitnode/example";
+
+const claimed = {
+ action: "publish" as const,
+ contentTypeId: testEditorialPostContentType.id,
+ createdBy: 3,
+ id: 55,
+ itemId: 7,
+ pluginId: PLUGIN_ID,
+};
+
+const row = {
+ createdAt: new Date("2026-08-01T09:00:00.000Z"),
+ id: 7,
+ publishedAt: new Date("2026-08-05T12:00:00.000Z"),
+ slug: "hello-world",
+ status: "published",
+ title: "Hello world",
+ updatedAt: new Date("2026-08-05T12:00:00.000Z"),
+ version: 4,
+};
+
+const outcome = {
+ changed: true,
+ changedFields: [],
+ operation: "publish" as const,
+ previousSlug: "hello-world",
+ restoredFromRevisionId: null,
+ revisionId: 90,
+ row,
+ version: 4,
+};
+
+const harness = ({
+ editorial,
+ registered = true,
+}: {
+ editorial?: Partial>;
+ registered?: boolean;
+} = {}) => {
+ const publish = vi.fn().mockResolvedValue(outcome);
+ const unpublish = vi.fn().mockResolvedValue(outcome);
+
+ const model = {
+ definition: testEditorialPostContentType,
+ editorialService: () => ({ publish, unpublish, ...editorial }),
+ };
+
+ const dispatch = vi.fn().mockResolvedValue({ id: 1 });
+ let committed = false;
+
+ const db = {
+ transaction: async (fn: (tx: unknown) => Promise) => {
+ const result = await fn({ tx: true });
+ committed = true;
+
+ return result;
+ },
+ };
+
+ const c = {
+ get: (key: string) =>
+ key === "db"
+ ? db
+ : key === "queue"
+ ? { dispatch }
+ : key === "core"
+ ? {
+ contentModels: registered
+ ? [{ model, pluginId: PLUGIN_ID }]
+ : [],
+ }
+ : undefined,
+ } as unknown as Context;
+
+ return { c, committed: () => committed, dispatch, publish, unpublish };
+};
+
+/** The single argument every effects dispatch carries. */
+const dispatchedPayload = (dispatch: ReturnType) =>
+ dispatch.mock.calls[0][0] as {
+ name: string;
+ payload: Record;
+ pluginId: string;
+ tx?: unknown;
+ };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ settleContentSchedule.mockResolvedValue(true);
+});
+
+describe("executeContentSchedule", () => {
+ it("publishes, settles the schedule, and queues the announcements", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch, publish } = harness();
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("executed");
+ expect(publish).toHaveBeenCalledTimes(1);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(dispatchedPayload(dispatch).name).toBe("content-schedule-effects");
+ });
+
+ describe("one transaction, from the claim to the commit", () => {
+ it("claims, transitions, settles and dispatches on the same handle", async () => {
+ // The whole point of the fix. Every one of these ran against the same
+ // `tx`, so the row lock `claimContentSchedule` takes is still held when
+ // the transition commits - which is what makes a concurrent cancel wait
+ // rather than succeed and then be ignored.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch, publish } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ const tx = { tx: true };
+ expect(claimContentSchedule).toHaveBeenCalledWith(tx, expect.anything());
+ expect(publish.mock.calls[0][1]).toMatchObject({ tx });
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ tx,
+ 55,
+ expect.anything(),
+ );
+ expect(dispatchedPayload(dispatch).tx).toEqual(tx);
+ });
+
+ it("dispatches the effects before the transaction commits", async () => {
+ // If the queue row could land after the commit, a crash in between would
+ // leave a published record nobody was ever told about.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, committed, dispatch } = harness();
+
+ dispatch.mockImplementation(async () => {
+ expect(committed()).toBe(false);
+
+ return Promise.resolve({ id: 1 });
+ });
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ });
+
+ it("settles only while the schedule is still pending", async () => {
+ // The guard that stops a stale worker overwriting `cancelled` with
+ // `completed`.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ {
+ expectedStatus: "pending",
+ lastError: null,
+ status: "completed",
+ },
+ );
+ });
+
+ it("rolls the transition back when the schedule is no longer pending", async () => {
+ // Structurally impossible while the lock is held - so if it happens the
+ // lock was not held, and publishing a cancelled plan is the worse of the
+ // two outcomes.
+ claimContentSchedule.mockResolvedValue(claimed);
+ settleContentSchedule.mockResolvedValue(false);
+ const { c, dispatch } = harness();
+
+ await expect(
+ executeContentSchedule(c, { generation: 1, scheduleId: 55 }),
+ ).rejects.toThrow(/no longer pending/);
+
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+ });
+
+ it("runs as the system, never as a made-up user", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, publish } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(publish.mock.calls[0][1]).toMatchObject({
+ actor: { type: "system", userId: null },
+ });
+ });
+
+ describe("the effects payload", () => {
+ it("names the person who booked it", async () => {
+ // The actor is genuinely the system, so "on whose instruction" has to
+ // come from somewhere else - and it is the whole point of the audit
+ // trail.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).payload).toMatchObject({
+ contentTypeId: testEditorialPostContentType.id,
+ itemId: 7,
+ operation: "publish",
+ pluginId: PLUGIN_ID,
+ revisionId: 90,
+ scheduleId: 55,
+ scheduledBy: 3,
+ version: 4,
+ });
+ });
+
+ it("says the record was private before a publish", async () => {
+ // Derived from the transition's own guard rather than read back outside
+ // the lock: `publish` only changes a row that was not published.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(false);
+ });
+
+ it("says the record was public before an unpublish", async () => {
+ claimContentSchedule.mockResolvedValue({
+ ...claimed,
+ action: "unpublish",
+ });
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(true);
+ });
+
+ it("is JSON, so the queue can store and replay it", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ const { row: stored } = dispatchedPayload(dispatch).payload as {
+ row: Record;
+ };
+ expect(stored.publishedAt).toBe("2026-08-05T12:00:00.000Z");
+ expect(stored.title).toBe("Hello world");
+ });
+
+ it("is stamped with core, so the worker can find the handler", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).pluginId).toBe("@vitnode/core");
+ });
+ });
+
+ describe("no-ops", () => {
+ it("does nothing when the row is cancelled, superseded or not yet due", async () => {
+ // All four guards collapse to the same answer from `claim`, so this is
+ // one test rather than four identical ones.
+ claimContentSchedule.mockResolvedValue(null);
+ const { c, dispatch, publish } = harness();
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("skipped");
+ expect(publish).not.toHaveBeenCalled();
+ // The load-bearing part: a superseded task must not touch search or the
+ // cache, or a cancelled plan would still expire a live page.
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(settleContentSchedule).not.toHaveBeenCalled();
+ });
+
+ it("does nothing more when the record is already published", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness({
+ editorial: {
+ publish: vi.fn().mockResolvedValue({ ...outcome, changed: false }),
+ },
+ });
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("skipped");
+ expect(dispatch).not.toHaveBeenCalled();
+ // Still settled, or it would be retried forever for a record that is
+ // already in the state the schedule wanted.
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ {
+ expectedStatus: "pending",
+ lastError: null,
+ status: "completed",
+ },
+ );
+ });
+
+ it("does nothing when the record was deleted first", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness({
+ editorial: { publish: vi.fn().mockResolvedValue(null) },
+ });
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("skipped");
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+ });
+
+ it("cancels rather than retrying when the content type is gone", async () => {
+ // A plugin removed, or `editorial` turned off. An error every ten minutes
+ // forever is not a useful way to report a config change.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness({ registered: false });
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("unregistered");
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ expect.objectContaining({
+ expectedStatus: "pending",
+ status: "cancelled",
+ }),
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it("records the error and rethrows a real failure", async () => {
+ // This one *is* worth retrying, and the queue's backoff is the policy.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c } = harness({
+ editorial: {
+ publish: vi.fn().mockRejectedValue(new Error("deadlock detected")),
+ },
+ });
+
+ await expect(
+ executeContentSchedule(c, { generation: 1, scheduleId: 55 }),
+ ).rejects.toThrow("deadlock detected");
+
+ expect(settleContentSchedule).toHaveBeenCalledWith(expect.anything(), 55, {
+ expectedStatus: "pending",
+ lastError: "deadlock detected",
+ });
+ // Left pending, so the AdminCP shows it as overdue rather than done.
+ expect(settleContentSchedule).not.toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ expect.objectContaining({ status: "completed" }),
+ );
+ });
+
+ it("passes the generation straight through to the claim", async () => {
+ claimContentSchedule.mockResolvedValue(null);
+ const { c } = harness();
+
+ await executeContentSchedule(c, { generation: 4, scheduleId: 55 });
+
+ expect(claimContentSchedule).toHaveBeenCalledWith(expect.anything(), {
+ generation: 4,
+ scheduleId: 55,
+ });
+ });
+});
diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
new file mode 100644
index 000000000..b76415c59
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
@@ -0,0 +1,242 @@
+import type { Context } from "hono";
+
+import type { ContentEditorialOutcome } from "@/content/server/editorial-service";
+import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects";
+import type { AnyContentTypeDefinition } from "@/content/types";
+
+import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const";
+import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor";
+import { findContentModel } from "@/content/server/model";
+import {
+ claimContentSchedule,
+ settleContentSchedule,
+} from "@/content/server/schedules-model";
+
+/** What the run decided, so the task logs something worth reading. */
+export interface ContentScheduleOutcome {
+ reason?: string;
+ status: "executed" | "skipped" | "unregistered";
+}
+
+/**
+ * Thrown when a claimed schedule is no longer `pending` at settlement time.
+ *
+ * Structurally impossible: the row is locked `FOR UPDATE` from the claim to the
+ * commit, so nothing else can have moved it. If it ever happens the lock was
+ * not held, and rolling the whole transition back is the only safe answer -
+ * publishing a record whose schedule somebody cancelled is worse than not
+ * publishing it.
+ */
+class ContentScheduleSettlementError extends Error {
+ constructor(scheduleId: number) {
+ super(
+ `Schedule ${scheduleId} was no longer pending at settlement time. Rolling the transition back.`,
+ );
+
+ this.name = "ContentScheduleSettlementError";
+ }
+}
+
+type ScheduleTransaction =
+ | { contentTypeId: string; kind: "unregistered" }
+ | { effects: ContentScheduleEffectsPayload; kind: "executed" }
+ | { kind: "skipped"; reason: string };
+
+const slugOf = (
+ definition: AnyContentTypeDefinition,
+ row: null | Record | undefined,
+): null | string => {
+ if (!definition.publicApi.enabled) return null;
+
+ const value = row?.[definition.publicApi.slugField];
+
+ return typeof value === "string" ? value : null;
+};
+
+/**
+ * Everything the announcements need, frozen at the moment the transition
+ * committed.
+ *
+ * `wasPublic` is derived rather than read back: the transition guards on the
+ * state it is leaving (`status <> 'published'` to publish, `= 'published'` to
+ * unpublish), so a *changed* publish came from a non-public row and a changed
+ * unpublish from a public one. That removes the extra `SELECT` the old code did
+ * outside the lock, and removes with it the window where the answer could have
+ * been someone else's write.
+ */
+const effectsPayload = ({
+ claimed,
+ definition,
+ outcome,
+ pluginId,
+}: {
+ claimed: {
+ action: "publish" | "unpublish";
+ createdBy: null | number;
+ id: number;
+ itemId: number;
+ };
+ definition: AnyContentTypeDefinition;
+ outcome: ContentEditorialOutcome;
+ pluginId: string;
+}): ContentScheduleEffectsPayload => {
+ const row = outcome.row as unknown as Record;
+
+ return {
+ changedFields: [...outcome.changedFields] as string[],
+ contentTypeId: definition.id,
+ itemId: claimed.itemId,
+ operation: claimed.action,
+ pluginId,
+ // A publish and an unpublish move `status`, never a field value, so the
+ // slug the record answered to before is the one it answers to now. Carried
+ // anyway, because the cache bridge takes a list and a future action that
+ // *does* move it should not need this file to change.
+ previousSlug: outcome.previousSlug ?? slugOf(definition, row),
+ revisionId: outcome.revisionId,
+ row: JSON.parse(JSON.stringify(row)) as Record,
+ scheduleId: claimed.id,
+ scheduledBy: claimed.createdBy,
+ version: outcome.version,
+ wasPublic: claimed.action === "unpublish",
+ };
+};
+
+/**
+ * Runs one scheduled transition, or decides not to.
+ *
+ * **One transaction, from the claim to the commit.** The old shape claimed in a
+ * short transaction of its own and released the row lock before publishing,
+ * which left a real window: an administrator could cancel, be told it worked,
+ * and watch the article go live anyway. Now the `FOR UPDATE` taken by
+ * `claimContentSchedule` is held until the transition, its revision, the
+ * settlement *and* the effects task have all committed - so a concurrent cancel
+ * either wins outright (before the claim) or waits and then finds the schedule
+ * already `completed`, which is a truthful 404 rather than a lie.
+ *
+ * What is deliberately **not** in the transaction: the event, the search write
+ * and the cache bridge. They talk to systems a rollback cannot reach, so they
+ * are handed to `content-schedule-effects` - a queue row written in this same
+ * transaction, and therefore present exactly when the transition committed.
+ *
+ * Almost every guard here is a **silent no-op**, and that is the design rather
+ * than laziness: each one describes a schedule that is no longer the plan -
+ * cancelled, rescheduled, or already run. Throwing would send the queue into a
+ * retry loop over a decision that is never going to change.
+ */
+export const executeContentSchedule = async (
+ c: Context,
+ { generation, scheduleId }: { generation: number; scheduleId: number },
+): Promise => {
+ const db = c.get("db");
+
+ let result: ScheduleTransaction;
+ try {
+ result = await db.transaction(async (tx): Promise => {
+ const claimed = await claimContentSchedule(tx, {
+ generation,
+ scheduleId,
+ });
+
+ if (!claimed) {
+ return {
+ kind: "skipped",
+ reason: "not pending, superseded, or not yet due",
+ };
+ }
+
+ const entry = findContentModel(
+ c.get("core").contentModels,
+ claimed.contentTypeId,
+ );
+ const editorialService = entry?.model.editorialService;
+
+ // The plugin was removed, or the content type dropped its editorial
+ // block. There is nothing to publish and there never will be, so cancel
+ // rather than retrying until the queue gives up - an error every ten
+ // minutes forever is not a useful way to report a config change.
+ if (!entry || !editorialService) {
+ await settleContentSchedule(tx, claimed.id, {
+ expectedStatus: "pending",
+ lastError: `Content type "${claimed.contentTypeId}" is no longer registered with an editorial workflow.`,
+ status: "cancelled",
+ });
+
+ return { contentTypeId: claimed.contentTypeId, kind: "unregistered" };
+ }
+
+ const { model, pluginId } = entry;
+
+ const outcome = await editorialService(c, { pluginId })[claimed.action](
+ claimed.itemId,
+ {
+ // No fake user id anywhere. Who *asked* for this is on the schedule
+ // row and travels in the event as `scheduledBy`.
+ actor: CONTENT_SYSTEM_ACTOR,
+ tx,
+ },
+ );
+
+ // Settled whatever happened. A record that was deleted first, or is
+ // already in the state the schedule wanted, is still a schedule that
+ // has had its answer - leaving it pending would retry it forever.
+ if (
+ !(await settleContentSchedule(tx, claimed.id, {
+ expectedStatus: "pending",
+ lastError: null,
+ status: "completed",
+ }))
+ ) {
+ throw new ContentScheduleSettlementError(claimed.id);
+ }
+
+ if (!outcome) {
+ return { kind: "skipped", reason: "record no longer exists" };
+ }
+ if (!outcome.changed) {
+ return { kind: "skipped", reason: "already in that state" };
+ }
+
+ const effects = effectsPayload({
+ claimed,
+ definition: model.definition,
+ outcome,
+ pluginId,
+ });
+
+ // In the transaction, so the announcement task exists if and only if
+ // the transition it announces committed. A crash a millisecond later
+ // loses nothing: the row is durable and the queue will drain it.
+ await c.get("queue").dispatch({
+ name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS,
+ payload: effects,
+ // Core owns the handler. Without this the row would be stamped with
+ // the requesting plugin's id and nothing would ever claim it.
+ pluginId: "@vitnode/core",
+ tx,
+ });
+
+ return { effects, kind: "executed" };
+ });
+ } catch (error) {
+ // Outside the rolled-back transaction, and guarded on `pending`: by now the
+ // lock is gone, so a cancel may legitimately have won the row.
+ await settleContentSchedule(db, scheduleId, {
+ expectedStatus: "pending",
+ lastError: error instanceof Error ? error.message : "Unknown error",
+ });
+
+ // Rethrown on purpose: this one *is* worth retrying, and the queue's
+ // backoff is the retry policy.
+ throw error;
+ }
+
+ if (result.kind === "unregistered") {
+ return { reason: result.contentTypeId, status: "unregistered" };
+ }
+ if (result.kind === "skipped") {
+ return { reason: result.reason, status: "skipped" };
+ }
+
+ return { status: "executed" };
+};
diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts
new file mode 100644
index 000000000..4583100f7
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts
@@ -0,0 +1,38 @@
+import { buildQueueTask } from "@/api/lib/queue";
+import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const";
+import {
+ contentScheduleEffectsPayloadSchema,
+ runContentScheduleEffects,
+} from "@/content/server/schedule-effects";
+
+/**
+ * Announces a scheduled transition that has already committed.
+ *
+ * Unlike `content-schedule`, the payload here **is** data rather than a pointer,
+ * and deliberately so: the record may have been edited again by the time this
+ * runs, and an event describing the record's current state would announce
+ * something other than the publication it is reporting. Everything travels
+ * frozen from the transaction that wrote it.
+ *
+ * Five attempts rather than three. The failures this retries are transient by
+ * nature - a search node restarting, a web app redeploying - and the backoff
+ * (10s, 20s, 40s, 80s) is a far better fit for those than for a deadlock.
+ */
+export const contentScheduleEffectsQueueTask = buildQueueTask({
+ name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS,
+ description:
+ "Emit the event, sync search and expire the cache for a scheduled publish or unpublish that has already committed. Never republishes.",
+ maxAttempts: 5,
+ handler: async (c, payload) => {
+ const input = contentScheduleEffectsPayloadSchema.parse(payload);
+ const outcome = await runContentScheduleEffects(c, input);
+
+ if (outcome.status === "unregistered") {
+ await c
+ .get("log")
+ .warn(
+ `[content-schedule-effects] ${input.scheduleId}: ${input.contentTypeId} is no longer registered, so nothing was announced.`,
+ );
+ }
+ },
+});
diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts
new file mode 100644
index 000000000..206152bd4
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+
+import { buildQueueTask } from "@/api/lib/queue";
+import { CONTENT_QUEUE_TASK_SCHEDULE } from "@/content/const";
+
+import { executeContentSchedule } from "../helpers/execute-content-schedule";
+
+/**
+ * The payload is a **pointer**, not data.
+ *
+ * Everything that matters - which record, which action, whether it is still
+ * wanted - is re-read from the schedule row under a lock. A payload carrying
+ * the action would go stale the moment somebody rescheduled, and a payload
+ * carrying the item id would be a way to publish an arbitrary record by
+ * inserting a queue row.
+ */
+export const contentSchedulePayloadSchema = z.object({
+ generation: z.number().int().positive(),
+ scheduleId: z.number().int().positive(),
+});
+
+export const contentScheduleQueueTask = buildQueueTask({
+ name: CONTENT_QUEUE_TASK_SCHEDULE,
+ description:
+ "Publish or unpublish a content record at its scheduled time. A cancelled, rescheduled or already-executed schedule is a no-op.",
+ handler: async (c, payload) => {
+ const { generation, scheduleId } =
+ contentSchedulePayloadSchema.parse(payload);
+
+ const outcome = await executeContentSchedule(c, { generation, scheduleId });
+ if (outcome.status === "executed") return;
+
+ const message = `[content-schedule] ${scheduleId}: ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ""}`;
+
+ // A skip is the normal, healthy outcome for a superseded task, so it is
+ // `debug` - but silence would make "the schedule never fired" impossible to
+ // tell from "it fired and correctly did nothing". An unregistered content
+ // type is a real misconfiguration, so that one is a warning.
+ await (outcome.status === "unregistered"
+ ? c.get("log").warn(message)
+ : c.get("log").debug(message));
+ },
+});
diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index 9119a04f9..45cd3deb4 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -2,6 +2,7 @@ import { CONFIG_PLUGIN } from "@/config";
import { buildApiPlugin } from "./lib/plugin";
import { adminModule } from "./modules/admin/admin.module";
+import { contentModule } from "./modules/content/content.module";
import { cronModule } from "./modules/cron/cron.module";
import { middlewareModule } from "./modules/middleware/middleware.module";
import { queueModule } from "./modules/queue/queue.module";
@@ -14,6 +15,7 @@ export const newBuildPluginApiCore = buildApiPlugin({
middlewareModule,
usersModule,
adminModule,
+ contentModule,
cronModule,
queueModule,
searchModule,
diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts
index d153202b1..b250e8eea 100644
--- a/packages/vitnode/src/content/admin/spec.ts
+++ b/packages/vitnode/src/content/admin/spec.ts
@@ -59,14 +59,16 @@ export type ContentEnumLabeller = (name: string, value: string) => string;
/**
* Generated columns have no field descriptor to read a kind from, so they are
* mapped by name. `status` gets its own kind rather than falling into "system",
- * which the cell renderer treats as a date.
+ * which the cell renderer treats as a date - and `version` is mapped to
+ * "number" for the same reason, since it is one.
*/
-const systemKinds: Record = {
+const systemKinds: Record = {
createdAt: "system",
id: "system",
publishedAt: "system",
status: "publication",
updatedAt: "system",
+ version: "number",
};
/** Projects a definition's form fields into the serialisable spec. */
diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts
index ede4c02ea..6be317b9c 100644
--- a/packages/vitnode/src/content/cache.ts
+++ b/packages/vitnode/src/content/cache.ts
@@ -38,6 +38,16 @@ export const contentPublicSlugTag = (
slug: string,
): string => tag(contentTypeId, "slug", slug);
+/**
+ * How hard a mutation expires the tags it touched.
+ *
+ * Lives here, in the client-safe layer, because the background
+ * [bridge](./server/revalidate-bridge.ts) has to name a mode from a process
+ * where `next/cache` cannot even be imported. `content/next` re-exports it, so
+ * the public name has not moved.
+ */
+export type ContentInvalidationMode = "immediate" | "stale-while-revalidate";
+
export interface ContentInvalidationInput {
contentTypeId: string;
id: number;
diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts
new file mode 100644
index 000000000..ac3e2fbbd
--- /dev/null
+++ b/packages/vitnode/src/content/conflicts.ts
@@ -0,0 +1,123 @@
+import { z } from "zod";
+
+import {
+ CONTENT_CONFLICT_CODES,
+ CONTENT_SCHEDULE_CODES,
+ CONTENT_UNPROCESSABLE_CODES,
+} from "./const";
+
+export type ContentConflictCode =
+ (typeof CONTENT_CONFLICT_CODES)[keyof typeof CONTENT_CONFLICT_CODES];
+
+export type ContentUnprocessableCode =
+ (typeof CONTENT_UNPROCESSABLE_CODES)[keyof typeof CONTENT_UNPROCESSABLE_CODES];
+
+/**
+ * The 409 body an editorial route answers with.
+ *
+ * A discriminated union so one OpenAPI schema describes the whole status: a
+ * generated client branches on `code` rather than parsing English. Only
+ * editorial content types answer this way - a Stage 1-3 route keeps the plain
+ * text 409 it has always returned, so nothing existing changes shape.
+ */
+export const zodContentConflict = z.discriminatedUnion("code", [
+ z.object({
+ code: z.literal(CONTENT_CONFLICT_CODES.version),
+ contentTypeId: z.string(),
+ currentVersion: z.number().int(),
+ expectedVersion: z.number().int(),
+ itemId: z.number().int(),
+ }),
+ z.object({
+ code: z.literal(CONTENT_CONFLICT_CODES.unique),
+ contentTypeId: z.string(),
+ itemId: z.number().int().nullable(),
+ }),
+]);
+
+export type ContentConflict = z.infer;
+
+/** The 422 body a restore answers with when the snapshot no longer fits. */
+export const zodContentUnprocessable = z.object({
+ code: z.literal(CONTENT_UNPROCESSABLE_CODES.notRestorable),
+ contentTypeId: z.string(),
+ /**
+ * The content type's own field names, and nothing else. Never a Zod issue
+ * tree - that names internal paths, and the route's OpenAPI schema already
+ * describes the contract.
+ */
+ fields: z.array(z.string()),
+ revisionId: z.number().int(),
+});
+
+export type ContentUnprocessable = z.infer;
+
+/**
+ * The 400 body a refused schedule answers with.
+ *
+ * A code rather than prose for the same reason the 409 carries one: the dialog
+ * points at the date field for one of these and shows a general error for the
+ * other, and it cannot branch on English.
+ */
+export const zodContentScheduleRejection = z.object({
+ code: z.enum([
+ CONTENT_SCHEDULE_CODES.inPast,
+ CONTENT_SCHEDULE_CODES.order,
+ CONTENT_SCHEDULE_CODES.unsupported,
+ ]),
+ contentTypeId: z.string(),
+});
+
+export type ContentScheduleRejection = z.infer<
+ typeof zodContentScheduleRejection
+>;
+
+/** Reads a schedule rejection out of a response body, or `null`. */
+export const parseContentScheduleRejection = (
+ body: string | undefined,
+): ContentScheduleRejection | null => {
+ if (body === undefined || body === "") return null;
+
+ try {
+ const parsed = zodContentScheduleRejection.safeParse(JSON.parse(body));
+
+ return parsed.success ? parsed.data : null;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Reads a structured error out of a response body.
+ *
+ * Returns `null` for anything that does not match - a plain-text 409 from a
+ * non-editorial route, an HTML error page from a proxy - so a caller can fall
+ * back to its generic message instead of throwing on the error path.
+ */
+export const parseContentConflict = (
+ body: string | undefined,
+): ContentConflict | null => {
+ if (body === undefined || body === "") return null;
+
+ try {
+ const parsed = zodContentConflict.safeParse(JSON.parse(body));
+
+ return parsed.success ? parsed.data : null;
+ } catch {
+ return null;
+ }
+};
+
+export const parseContentUnprocessable = (
+ body: string | undefined,
+): ContentUnprocessable | null => {
+ if (body === undefined || body === "") return null;
+
+ try {
+ const parsed = zodContentUnprocessable.safeParse(JSON.parse(body));
+
+ return parsed.success ? parsed.data : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts
index 22db51cf8..3e4ea6ffb 100644
--- a/packages/vitnode/src/content/const.ts
+++ b/packages/vitnode/src/content/const.ts
@@ -2,6 +2,16 @@ export const CONTENT_SYSTEM_FIELDS = ["id", "createdAt", "updatedAt"] as const;
export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const;
+/**
+ * The column `editorial: { enabled: true }` adds.
+ *
+ * Its own list rather than an entry in `CONTENT_SYSTEM_FIELDS`, for the same
+ * reason the publication fields are separate: it exists only for a content type
+ * that opted in, so a Stage 1 type stays free to declare a field of its own
+ * called `version`.
+ */
+export const CONTENT_EDITORIAL_FIELDS = ["version"] as const;
+
export const CONTENT_PUBLICATION_STATUSES = ["draft", "published"] as const;
const publicationStatuses: ReadonlySet = new Set(
@@ -129,14 +139,155 @@ export const CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH = 100;
export const CONTENT_SEARCH_PATH_MAX_LENGTH = 512;
+/**
+ * What a revision records.
+ *
+ * One per *real* mutation - a no-op update, an idempotent publish and a
+ * cancelled schedule all write nothing at all.
+ */
+export const CONTENT_REVISION_OPERATIONS = [
+ "create",
+ "delete",
+ "publish",
+ "restore",
+ "unpublish",
+ "update",
+] as const;
+
+/**
+ * Who performed a mutation.
+ *
+ * `system` exists so a scheduled publish needs no fake user id. Who *created*
+ * the schedule is kept on the schedule row, not invented here.
+ */
+export const CONTENT_ACTOR_TYPES = ["api", "staff", "system"] as const;
+
+/** The envelope `snapshot` is stored in, so a future shape change is visible. */
+export const CONTENT_REVISION_SNAPSHOT_VERSION = 1;
+
+/**
+ * How many of the newest revisions are kept per record.
+ *
+ * Pruned in the same transaction that writes the new one, so the table stays
+ * bounded without a background job - an install with no cron adapter must not
+ * grow forever.
+ */
+export const CONTENT_REVISION_DEFAULT_RETENTION = 50;
+export const CONTENT_REVISION_MIN_RETENTION = 1;
+export const CONTENT_REVISION_MAX_RETENTION = 500;
+
+/**
+ * How long a preview link stays valid.
+ *
+ * The ceiling is a day: a preview token is a bearer credential for an
+ * unpublished record, and its expiry is the only thing that revokes it.
+ */
+export const CONTENT_PREVIEW_DEFAULT_TTL_MINUTES = 15;
+export const CONTENT_PREVIEW_MIN_TTL_MINUTES = 1;
+export const CONTENT_PREVIEW_MAX_TTL_MINUTES = 1440;
+
+/** The only placeholder `editorial.preview.pathTemplate` may use. */
+export const CONTENT_PREVIEW_TOKEN_PLACEHOLDER = "{token}";
+
+/**
+ * The preview token format.
+ *
+ * Carried inside the signed payload so a future change to the shape is a
+ * rejected token rather than a misread one - old links stop working, which is
+ * the correct outcome for a credential whose meaning moved.
+ */
+export const CONTENT_PREVIEW_TOKEN_VERSION = 1;
+
+export const CONTENT_PREVIEW_PATH_MAX_LENGTH = 512;
+
+/** What a schedule does when it fires. */
+export const CONTENT_SCHEDULE_ACTIONS = ["publish", "unpublish"] as const;
+
+/**
+ * Where a schedule is in its life.
+ *
+ * There is deliberately no `failed`. Marking one would need the handler to know
+ * the queue row's attempt count, which it never receives - and an overdue
+ * `pending` row with `lastError` set says the same thing with one fewer state
+ * that can be wrong.
+ */
+export const CONTENT_SCHEDULE_STATUSES = [
+ "cancelled",
+ "completed",
+ "pending",
+] as const;
+
+/**
+ * How far in the past a `scheduledFor` may be and still be accepted.
+ *
+ * One cron tick plus slack. A browser clock a minute behind the server is
+ * ordinary, and "now" is what the editor meant - rejecting it would be a
+ * puzzle, not a safeguard. Anything earlier is a mistake worth naming.
+ */
+export const CONTENT_SCHEDULE_PAST_TOLERANCE_MS = 120_000;
+
+/** How long a completed or cancelled schedule is kept as an audit trail. */
+export const CONTENT_SCHEDULE_RETENTION_DAYS = 30;
+
+/**
+ * The single core queue task that executes every content schedule.
+ *
+ * One task rather than one per content type: `queueTasks` are collected from
+ * top-level modules only, and `buildContentAdminModule` is nested inside a
+ * plugin's admin module - so a task registered there would be silently dropped.
+ */
+export const CONTENT_QUEUE_TASK_SCHEDULE = "content-schedule";
+
+/**
+ * The follow-up task that announces a schedule that has already happened.
+ *
+ * Separate from {@link CONTENT_QUEUE_TASK_SCHEDULE} because the two have
+ * different failure meanings. The transition is a database write that either
+ * committed or did not; the effects are an event, a search write and an HTTP
+ * hop to another process, any of which can fail long after the record is
+ * already published. Retrying them together would re-run an idempotent publish
+ * that then skips its own announcements - which is how a scheduled unpublish
+ * ends up permanently missing its cache invalidation.
+ *
+ * Dispatched **inside** the transition's transaction, so the task exists if and
+ * only if the transition committed.
+ */
+export const CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS = "content-schedule-effects";
+
+/** Machine-readable reasons a schedule was refused. */
+export const CONTENT_SCHEDULE_CODES = {
+ inPast: "CONTENT_SCHEDULE_IN_PAST",
+ order: "CONTENT_SCHEDULE_ORDER",
+ unsupported: "CONTENT_SCHEDULE_UNSUPPORTED",
+} as const;
+
/**
* Every content type gets the first four staff permissions. `can_publish` is
- * generated only for content types with `publication: { enabled: true }`.
+ * generated only for content types with `publication: { enabled: true }`, and
+ * `can_restore` only for those with `editorial: { enabled: true }`.
*/
export const CONTENT_PERMISSIONS = {
create: "can_create",
delete: "can_delete",
edit: "can_edit",
publish: "can_publish",
+ restore: "can_restore",
view: "can_view",
} as const;
+
+/**
+ * Machine-readable reasons a write was refused.
+ *
+ * A code rather than a sentence, because the AdminCP has to *act* on the
+ * difference - a version conflict reloads the record and offers to overwrite, a
+ * unique clash points at a field. Prose cannot be branched on, and the
+ * driver's own message must never reach a client.
+ */
+export const CONTENT_CONFLICT_CODES = {
+ unique: "CONTENT_UNIQUE_CONFLICT",
+ version: "CONTENT_VERSION_CONFLICT",
+} as const;
+
+export const CONTENT_UNPROCESSABLE_CODES = {
+ notRestorable: "CONTENT_REVISION_NOT_RESTORABLE",
+} as const;
diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts
index 8521ab7a2..80e2fd8aa 100644
--- a/packages/vitnode/src/content/define.test.ts
+++ b/packages/vitnode/src/content/define.test.ts
@@ -8,6 +8,12 @@ import {
import type { ContentUserField } from "./types";
+import {
+ CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ CONTENT_REVISION_DEFAULT_RETENTION,
+ CONTENT_REVISION_MAX_RETENTION,
+ CONTENT_REVISION_MIN_RETENTION,
+} from "./const";
import { defineContentType } from "./define";
import { ContentEngineError } from "./errors";
import { field } from "./fields";
@@ -511,6 +517,204 @@ describe("defineContentType", () => {
});
});
+ describe("editorial", () => {
+ type Overrides = NonNullable[0]>;
+
+ const editorialDefine = (
+ editorial: Overrides["editorial"],
+ overrides: Overrides = {},
+ ) => define({ editorial, ...overrides });
+
+ const publishable = {
+ publication: { enabled: true } as const,
+ publicApi: {
+ enabled: true,
+ path: "widgets",
+ fields: ["title", "slug"],
+ } as const,
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ },
+ };
+
+ describe("defaults", () => {
+ it("resolves to disabled when omitted", () => {
+ expect(define().editorial).toEqual({
+ enabled: false,
+ preview: {
+ enabled: false,
+ expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ pathTemplate: null,
+ },
+ revisions: { retention: CONTENT_REVISION_DEFAULT_RETENTION },
+ scheduling: { enabled: false },
+ });
+ });
+
+ it("fills in the defaults when opted in with nothing else", () => {
+ expect(editorialDefine({ enabled: true }).editorial).toEqual({
+ enabled: true,
+ preview: {
+ enabled: false,
+ expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ pathTemplate: null,
+ },
+ revisions: { retention: CONTENT_REVISION_DEFAULT_RETENTION },
+ scheduling: { enabled: false },
+ });
+ });
+
+ it("keeps a declared retention", () => {
+ expect(
+ editorialDefine({ enabled: true, revisions: { retention: 5 } })
+ .editorial.revisions.retention,
+ ).toBe(5);
+ });
+ });
+
+ describe("retention validation", () => {
+ it.each([0, -1, 501, 1.5])("rejects a retention of %s", retention => {
+ expect(() =>
+ editorialDefine({ enabled: true, revisions: { retention } }),
+ ).toThrow(ContentEngineError);
+ });
+
+ it.each([CONTENT_REVISION_MIN_RETENTION, CONTENT_REVISION_MAX_RETENTION])(
+ "accepts the boundary %s",
+ retention => {
+ expect(() =>
+ editorialDefine({ enabled: true, revisions: { retention } }),
+ ).not.toThrow();
+ },
+ );
+ });
+
+ describe("preview", () => {
+ const withPreview = (preview: {
+ enabled: true;
+ expiresInMinutes?: number;
+ pathTemplate?: string;
+ }): ReturnType =>
+ editorialDefine({ enabled: true, preview }, publishable);
+
+ it("needs a public API", () => {
+ expect(() =>
+ editorialDefine(
+ { enabled: true, preview: { enabled: true } },
+ { publication: { enabled: true } },
+ ),
+ ).toThrow(/needs `publicApi/);
+ });
+
+ it("resolves its defaults", () => {
+ expect(withPreview({ enabled: true }).editorial.preview).toEqual({
+ enabled: true,
+ expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ pathTemplate: null,
+ });
+ });
+
+ it.each([0, 1441, 2.5])("rejects a TTL of %s minutes", value => {
+ expect(() =>
+ withPreview({ enabled: true, expiresInMinutes: value }),
+ ).toThrow(ContentEngineError);
+ });
+
+ it.each([
+ ["widgets/preview/{token}", "no leading slash"],
+ ["/widgets/preview", "no placeholder"],
+ ["/widgets/{token}/{token}", "two placeholders"],
+ ["/widgets/{id}/{token}", "an unsupported placeholder"],
+ ["/widgets//preview/{token}", "an empty segment"],
+ ["/widgets/../{token}", "a traversal"],
+ ["/widgets/pre view/{token}", "whitespace"],
+ ])("rejects the pathTemplate %s (%s)", pathTemplate => {
+ expect(() => withPreview({ enabled: true, pathTemplate })).toThrow(
+ ContentEngineError,
+ );
+ });
+
+ it("accepts a well-formed pathTemplate", () => {
+ expect(
+ withPreview({
+ enabled: true,
+ pathTemplate: "/widgets/preview/{token}",
+ }).editorial.preview.pathTemplate,
+ ).toBe("/widgets/preview/{token}");
+ });
+ });
+
+ describe("scheduling", () => {
+ it("needs publication", () => {
+ expect(() =>
+ editorialDefine({ enabled: true, scheduling: { enabled: true } }),
+ ).toThrow(/needs `publication/);
+ });
+
+ it("is enabled alongside publication", () => {
+ expect(
+ editorialDefine(
+ { enabled: true, scheduling: { enabled: true } },
+ { publication: { enabled: true } },
+ ).editorial.scheduling.enabled,
+ ).toBe(true);
+ });
+ });
+
+ describe("reserved field name", () => {
+ const versionField = {
+ title: field.text({ required: true }),
+ version: field.number({ integer: true, defaultValue: 0 }),
+ };
+
+ it("rejects a field called `version` once enabled", () => {
+ expect(() =>
+ editorialDefine({ enabled: true }, { fields: versionField }),
+ ).toThrow(/generated by `editorial`/);
+ });
+
+ it("allows it when editorial is omitted", () => {
+ expect(() => define({ fields: versionField })).not.toThrow();
+ });
+ });
+
+ it("rejects a content type id too long to store on a revision", () => {
+ expect(() =>
+ editorialDefine(
+ { enabled: true },
+ { id: `test.${"a".repeat(100)}`, tableName: "test_long_id" },
+ ),
+ ).toThrow(/limit for a revision/);
+ });
+
+ describe("addressable column", () => {
+ it("accepts `version` in the admin list once enabled", () => {
+ expect(
+ editorialDefine(
+ { enabled: true },
+ { admin: { label, list: { columns: ["title", "version"] } } },
+ ).admin.list.columns,
+ ).toEqual(["title", "version"]);
+ });
+
+ it("rejects it when editorial is off", () => {
+ expect(() =>
+ define({ admin: { label, list: { columns: ["title", "version"] } } }),
+ ).toThrow(/unknown field "version"/);
+ });
+
+ it("accepts an index over it once enabled", () => {
+ expect(() =>
+ editorialDefine(
+ { enabled: true },
+ { indexes: [{ on: ["version"] }] },
+ ),
+ ).not.toThrow();
+ });
+ });
+ });
+
describe("fixtures", () => {
it("resolves the article fixture", () => {
expect(testArticleContentType.permissionModule).toBe("test_articles");
diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts
index 64bd64d8f..b0cd3d98b 100644
--- a/packages/vitnode/src/content/define.ts
+++ b/packages/vitnode/src/content/define.ts
@@ -1,12 +1,16 @@
import type {
ContentAdminConfig,
+ ContentEditorialConfig,
+ ContentEditorialEnabled,
ContentFieldDescriptor,
ContentFieldMap,
ContentFieldsConstraint,
ContentIndexInput,
+ ContentPreviewEnabled,
ContentPublicApiConfig,
ContentPublicationConfig,
ContentPublicExposableField,
+ ContentSchedulingEnabled,
ContentSearchConfig,
ContentSearchDescriptionField,
ContentSearchEnabled,
@@ -14,16 +18,23 @@ import type {
ContentSearchTitleField,
ContentTypeDefinition,
ResolvedContentAdminConfig,
+ ResolvedContentEditorialConfig,
ResolvedContentPublicApiConfig,
ResolvedContentSearchConfig,
} from "./types";
import {
+ CONTENT_EDITORIAL_FIELDS,
CONTENT_ENUM_DEFAULT_LENGTH,
CONTENT_FIELD_NAME_PATTERN,
CONTENT_FILTERABLE_FIELD_KINDS,
CONTENT_ID_PATTERN,
CONTENT_IDENTIFIER_MAX_LENGTH,
+ CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ CONTENT_PREVIEW_MAX_TTL_MINUTES,
+ CONTENT_PREVIEW_MIN_TTL_MINUTES,
+ CONTENT_PREVIEW_PATH_MAX_LENGTH,
+ CONTENT_PREVIEW_TOKEN_PLACEHOLDER,
CONTENT_PUBLIC_ALWAYS_ORDERABLE,
CONTENT_PUBLIC_EXPOSABLE_COLUMNS,
CONTENT_PUBLIC_EXPOSABLE_KINDS,
@@ -31,6 +42,9 @@ import {
CONTENT_PUBLIC_PATH_PATTERN,
CONTENT_PUBLIC_RESERVED_PATHS,
CONTENT_PUBLICATION_FIELDS,
+ CONTENT_REVISION_DEFAULT_RETENTION,
+ CONTENT_REVISION_MAX_RETENTION,
+ CONTENT_REVISION_MIN_RETENTION,
CONTENT_SEARCH_DESCRIPTION_KINDS,
CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH,
CONTENT_SEARCH_PATH_MAX_LENGTH,
@@ -66,6 +80,7 @@ const SLUG_SOURCE_KINDS = new Set(["text"]);
const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS;
const publicationFields: readonly string[] = CONTENT_PUBLICATION_FIELDS;
+const editorialFields: readonly string[] = CONTENT_EDITORIAL_FIELDS;
const slugifyModule = (value: string): string =>
value
@@ -91,6 +106,7 @@ const assertFieldName = (
id: string,
name: string,
publication: boolean,
+ editorial: boolean,
): void => {
if (systemFields.includes(name)) {
throw new ContentEngineError(
@@ -106,6 +122,13 @@ const assertFieldName = (
);
}
+ if (editorial && editorialFields.includes(name)) {
+ throw new ContentEngineError(
+ `"${name}" is generated by \`editorial\` and cannot also be declared as a field. Rename the field, or drop \`editorial\` and manage versioning yourself.`,
+ { contentTypeId: id },
+ );
+ }
+
if (!CONTENT_FIELD_NAME_PATTERN.test(name)) {
throw new ContentEngineError(
`Field "${name}" must be camelCase and start with a lowercase letter.`,
@@ -286,11 +309,14 @@ const resolveAdmin = (
fields: ContentFieldMap,
admin: ContentAdminConfig,
publication: boolean,
+ editorial: boolean,
): ResolvedContentAdminConfig => {
const fieldNames = Object.keys(fields);
- const generatedColumns = publication
- ? [...systemFields, ...publicationFields]
- : systemFields;
+ const generatedColumns = [
+ ...systemFields,
+ ...(publication ? publicationFields : []),
+ ...(editorial ? editorialFields : []),
+ ];
const knownColumns = new Set([...fieldNames, ...generatedColumns]);
const searchableFields = (
@@ -785,6 +811,167 @@ const resolveSearch = (
};
};
+const disabledEditorial: ResolvedContentEditorialConfig = {
+ enabled: false,
+ preview: {
+ enabled: false,
+ expiresInMinutes: CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ pathTemplate: null,
+ },
+ revisions: { retention: CONTENT_REVISION_DEFAULT_RETENTION },
+ scheduling: { enabled: false },
+};
+
+/**
+ * The same rules as `search.pathTemplate`, with `{token}` in place of `{slug}`.
+ *
+ * Deliberately not shared with it: the two differ in their placeholder and in
+ * the config key their messages name, and a parameterised version would say
+ * less about what the author got wrong.
+ */
+const assertPreviewPathTemplate = (id: string, template: string): void => {
+ if (!template.startsWith("/")) {
+ throw new ContentEngineError(
+ `editorial.preview.pathTemplate "${template}" must start with "/". A preview URL is relative to the site root.`,
+ { contentTypeId: id },
+ );
+ }
+
+ if (template.length > CONTENT_PREVIEW_PATH_MAX_LENGTH) {
+ throw new ContentEngineError(
+ `editorial.preview.pathTemplate "${template}" is longer than ${CONTENT_PREVIEW_PATH_MAX_LENGTH} characters.`,
+ { contentTypeId: id },
+ );
+ }
+
+ const occurrences =
+ template.split(CONTENT_PREVIEW_TOKEN_PLACEHOLDER).length - 1;
+ if (occurrences !== 1) {
+ throw new ContentEngineError(
+ `editorial.preview.pathTemplate "${template}" must contain exactly one "${CONTENT_PREVIEW_TOKEN_PLACEHOLDER}" placeholder, not ${occurrences}.`,
+ { contentTypeId: id },
+ );
+ }
+
+ const rest = template.replace(CONTENT_PREVIEW_TOKEN_PLACEHOLDER, "");
+ if (rest.includes("{") || rest.includes("}")) {
+ throw new ContentEngineError(
+ `editorial.preview.pathTemplate "${template}" uses a placeholder other than "${CONTENT_PREVIEW_TOKEN_PLACEHOLDER}". No other placeholder is supported.`,
+ { contentTypeId: id },
+ );
+ }
+
+ if (rest.includes("//") || template.includes("..") || /\s/.test(template)) {
+ throw new ContentEngineError(
+ `editorial.preview.pathTemplate "${template}" must not contain an empty segment, "..", or whitespace.`,
+ { contentTypeId: id },
+ );
+ }
+};
+
+const assertInRange = ({
+ id,
+ label,
+ max,
+ min,
+ value,
+}: {
+ id: string;
+ label: string;
+ max: number;
+ min: number;
+ value: number;
+}): void => {
+ if (!Number.isInteger(value) || value < min || value > max) {
+ throw new ContentEngineError(
+ `${label} must be a whole number between ${min} and ${max}, got ${value}.`,
+ { contentTypeId: id },
+ );
+ }
+};
+
+/**
+ * Checks and fills in `editorial`.
+ *
+ * Runs last, because both sub-features are stated in terms of capabilities the
+ * other resolvers have already settled. The two dependency checks repeat what
+ * the types already say, for the same reason every other one does: a JavaScript
+ * caller, or a value that widened somewhere upstream, can reach this with
+ * anything at all.
+ */
+const resolveEditorial = (
+ id: string,
+ editorial: ContentEditorialConfig | undefined,
+ publicApi: ResolvedContentPublicApiConfig,
+ publication: boolean,
+): ResolvedContentEditorialConfig => {
+ if (!editorial?.enabled) return disabledEditorial;
+
+ const retention =
+ editorial.revisions?.retention ?? CONTENT_REVISION_DEFAULT_RETENTION;
+ assertInRange({
+ id,
+ label: "editorial.revisions.retention",
+ max: CONTENT_REVISION_MAX_RETENTION,
+ min: CONTENT_REVISION_MIN_RETENTION,
+ value: retention,
+ });
+
+ // A content type id is used verbatim as `core_content_revisions.contentTypeId`,
+ // which is varchar(100) - the same limit `search` enforces, and worth
+ // catching here rather than at the first insert.
+ if (id.length > CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH) {
+ throw new ContentEngineError(
+ `Content type id "${id}" is longer than ${CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH} characters, which is the limit for a revision's stored content type.`,
+ { contentTypeId: id },
+ );
+ }
+
+ const preview =
+ editorial.preview?.enabled === true ? editorial.preview : null;
+ if (preview && !publicApi.enabled) {
+ throw new ContentEngineError(
+ "editorial.preview needs `publicApi: { enabled: true, path, fields }`. A preview returns the public projection of a draft, so without a public allowlist there is nothing it could safely show.",
+ { contentTypeId: id },
+ );
+ }
+
+ const expiresInMinutes =
+ preview?.expiresInMinutes ?? CONTENT_PREVIEW_DEFAULT_TTL_MINUTES;
+ if (preview) {
+ assertInRange({
+ id,
+ label: "editorial.preview.expiresInMinutes",
+ max: CONTENT_PREVIEW_MAX_TTL_MINUTES,
+ min: CONTENT_PREVIEW_MIN_TTL_MINUTES,
+ value: expiresInMinutes,
+ });
+
+ if (preview.pathTemplate !== undefined) {
+ assertPreviewPathTemplate(id, preview.pathTemplate);
+ }
+ }
+
+ const scheduling = editorial.scheduling?.enabled === true;
+ if (scheduling && !publication) {
+ throw new ContentEngineError(
+ "editorial.scheduling needs `publication: { enabled: true }`. A schedule moves `status`, and without the lifecycle there is no status to move.",
+ { contentTypeId: id },
+ );
+ }
+
+ return {
+ enabled: true,
+ preview: {
+ enabled: preview !== null,
+ expiresInMinutes,
+ pathTemplate: preview?.pathTemplate ?? null,
+ },
+ revisions: { retention },
+ scheduling: { enabled: scheduling },
+ };
+};
+
/**
* Declares a content type. The result is plain data - zod and objects only -
* so the same definition can be imported by `buildPlugin` (client) and by
@@ -793,7 +980,10 @@ const resolveSearch = (
*/
export const defineContentType = <
TId extends string,
- TFields extends ContentFieldsConstraint,
+ TFields extends ContentFieldsConstraint<
+ TPublication,
+ ContentEditorialEnabled
+ >,
TPublication extends boolean = false,
TPublicField extends ContentPublicExposableField = never,
TPublicEnabled extends boolean = false,
@@ -815,8 +1005,18 @@ export const defineContentType = <
ContentSearchTextField
>
| { enabled: false } = { enabled: false },
+ // The whole `editorial` argument, inferred as one type, for the same two
+ // reasons `TSearch` is: its constraint is checked once `TPublicEnabled` and
+ // `TPublication` are resolved - which is what makes "preview needs a public
+ // API" and "scheduling needs publication" compile errors - and an
+ // intersection member is not an inference site, so inferring the object is
+ // the only way the three `enabled` literals survive.
+ TEditorial extends
+ ContentEditorialConfig | { enabled: false } =
+ { enabled: false },
>({
admin,
+ editorial,
fields,
id,
indexes = [],
@@ -825,10 +1025,24 @@ export const defineContentType = <
search,
tableName,
}: {
- admin: ContentAdminConfig;
+ admin: ContentAdminConfig<
+ TFields,
+ TPublication,
+ ContentEditorialEnabled
+ >;
+ /**
+ * Opts into the editorial workflow: a `version` column, optimistic locking
+ * and revision history, plus optional preview and scheduling. Omit it and
+ * nothing changes.
+ */
+ editorial?: TEditorial;
fields: TFields;
id: TId;
- indexes?: ContentIndexInput[];
+ indexes?: ContentIndexInput<
+ TFields,
+ TPublication,
+ ContentEditorialEnabled
+ >[];
/**
* Opts into a generated read-only public API. Needs `publication` and exactly
* one exposed slug field. Omit it and nothing public is generated.
@@ -850,7 +1064,10 @@ export const defineContentType = <
TPublication,
TPublicField,
TPublicEnabled,
- ContentSearchEnabled
+ ContentSearchEnabled,
+ ContentEditorialEnabled,
+ ContentPreviewEnabled,
+ ContentSchedulingEnabled
> => {
if (!CONTENT_ID_PATTERN.test(id)) {
throw new ContentEngineError(
@@ -885,9 +1102,10 @@ export const defineContentType = <
}
const publicationEnabled = publication?.enabled === true;
+ const editorialEnabled = editorial?.enabled === true;
for (const name of fieldNames) {
- assertFieldName(id, name, publicationEnabled);
+ assertFieldName(id, name, publicationEnabled, editorialEnabled);
assertFieldKind(id, name, fieldMap[name]);
assertField(id, name, fieldMap[name]);
}
@@ -898,6 +1116,7 @@ export const defineContentType = <
...fieldNames,
...systemFields,
...(publicationEnabled ? publicationFields : []),
+ ...(editorialEnabled ? editorialFields : []),
]);
const resolvedIndexes = resolveContentIndexes({
contentTypeId: id,
@@ -912,7 +1131,13 @@ export const defineContentType = <
tableName,
});
- const resolvedAdmin = resolveAdmin(id, fieldMap, admin, publicationEnabled);
+ const resolvedAdmin = resolveAdmin(
+ id,
+ fieldMap,
+ admin,
+ publicationEnabled,
+ editorialEnabled,
+ );
const permissionModule =
admin.permissionModule ?? slugifyModule(admin.label.plural);
@@ -943,8 +1168,22 @@ export const defineContentType = <
publicationEnabled,
);
+ const resolvedEditorial = resolveEditorial(
+ id,
+ // The `{ enabled: false }` arm of the parameter exists only so an explicit
+ // literal typechecks - the same widening `publicApi` and `search` do.
+ editorial as ContentEditorialConfig | undefined,
+ resolvedPublicApi,
+ publicationEnabled,
+ );
+
return {
admin: resolvedAdmin,
+ editorial: resolvedEditorial as ResolvedContentEditorialConfig<
+ ContentEditorialEnabled,
+ ContentPreviewEnabled,
+ ContentSchedulingEnabled
+ >,
fields,
id,
indexes: resolvedIndexes,
@@ -963,10 +1202,14 @@ export const defineContentType = <
TPublication,
TPublicField,
TPublicEnabled,
- ContentSearchEnabled
+ ContentSearchEnabled,
+ ContentEditorialEnabled,
+ ContentPreviewEnabled,
+ ContentSchedulingEnabled
>
>({
admin: resolvedAdmin,
+ editorial: editorialEnabled,
fields: fieldMap,
publicApi: resolvedPublicApi,
publication: publicationEnabled,
diff --git a/packages/vitnode/src/content/editorial.test-d.ts b/packages/vitnode/src/content/editorial.test-d.ts
new file mode 100644
index 000000000..486d9155a
--- /dev/null
+++ b/packages/vitnode/src/content/editorial.test-d.ts
@@ -0,0 +1,299 @@
+import { assertType, describe, expectTypeOf, it } from "vitest";
+
+import {
+ testArticleContentType,
+ testCategoryContentType,
+ testEditorialNoteContentType,
+ testEditorialPostContentType,
+ testPostContentType,
+ testSearchablePostContentType,
+} from "@/tests/content-fixtures";
+
+import type { ContentEventsFor } from "./events";
+import type {
+ AnyContentTypeDefinition,
+ ContentCreateInput,
+ ContentOrderableFieldName,
+ ContentSelect,
+ ContentUpdateInput,
+ EditorialContentTypeDefinition,
+ PreviewableContentTypeDefinition,
+ SchedulableContentTypeDefinition,
+} from "./types";
+
+import { defineContentType } from "./define";
+import { field } from "./fields";
+
+type Editorial = typeof testEditorialPostContentType;
+type Note = typeof testEditorialNoteContentType;
+type Post = typeof testPostContentType;
+
+describe("editorial", () => {
+ // Three more type parameters on `ContentTypeDefinition`, and this is what says
+ // they cost nothing: the erased form every relation thunk, registry and route
+ // builder is written against still accepts every concrete definition.
+ describe("assignability to AnyContentTypeDefinition", () => {
+ it("holds for an editorial content type", () => {
+ expectTypeOf().toExtend();
+ assertType(testEditorialPostContentType);
+ assertType(testEditorialNoteContentType);
+ });
+
+ it("still holds for every Stage 1-3 fixture", () => {
+ assertType(testCategoryContentType);
+ assertType(testArticleContentType);
+ assertType(testPostContentType);
+ assertType(testSearchablePostContentType);
+ });
+ });
+
+ describe("the flags stay literal", () => {
+ it("is `true` when opted in", () => {
+ expectTypeOf(
+ testEditorialPostContentType.editorial.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(
+ testEditorialPostContentType.editorial.preview.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(
+ testEditorialPostContentType.editorial.scheduling.enabled,
+ ).toEqualTypeOf();
+ });
+
+ it("is `false` when omitted", () => {
+ expectTypeOf(
+ testPostContentType.editorial.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(
+ testArticleContentType.editorial.enabled,
+ ).toEqualTypeOf();
+ });
+
+ it("keeps the two sub-features independent", () => {
+ // Revisions without publication, so neither sub-feature is expressible.
+ expectTypeOf(
+ testEditorialNoteContentType.editorial.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(
+ testEditorialNoteContentType.editorial.preview.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(
+ testEditorialNoteContentType.editorial.scheduling.enabled,
+ ).toEqualTypeOf();
+ });
+ });
+
+ describe("capability rules", () => {
+ it("allows revisions with no publication and no public API", () => {
+ expectTypeOf().toExtend();
+ });
+
+ it("rejects preview without a public API", () => {
+ defineContentType({
+ id: "test.no-public",
+ tableName: "test_no_public",
+ fields: { title: field.text({ required: true }) },
+ publication: { enabled: true },
+ editorial: {
+ enabled: true,
+ // @ts-expect-error - preview projects through `publicApi.fields`
+ preview: { enabled: true },
+ },
+ admin: { label: { plural: "Nopes", singular: "Nope" } },
+ });
+ });
+
+ it("rejects scheduling without publication", () => {
+ defineContentType({
+ id: "test.no-lifecycle",
+ tableName: "test_no_lifecycle",
+ fields: { title: field.text({ required: true }) },
+ editorial: {
+ enabled: true,
+ // @ts-expect-error - a schedule moves `status`
+ scheduling: { enabled: true },
+ },
+ admin: { label: { plural: "Nopes", singular: "Nope" } },
+ });
+ });
+ });
+
+ describe("narrowing intersections", () => {
+ it("pins the fully-configured content type", () => {
+ expectTypeOf().toExtend();
+ expectTypeOf().toExtend();
+ expectTypeOf().toExtend();
+ });
+
+ it("excludes a content type without the workflow", () => {
+ expectTypeOf().not.toExtend();
+ expectTypeOf().not.toExtend();
+ expectTypeOf().not.toExtend();
+ });
+
+ it("excludes an editorial content type that opted into neither extra", () => {
+ expectTypeOf().not.toExtend();
+ expectTypeOf().not.toExtend();
+ });
+ });
+
+ describe("select output", () => {
+ it("gains the generated version column", () => {
+ expectTypeOf<
+ ContentSelect["version"]
+ >().toEqualTypeOf();
+ expectTypeOf["version"]>().toEqualTypeOf();
+ });
+
+ it("adds nothing to a content type without the workflow", () => {
+ expectTypeOf>().not.toHaveProperty("version");
+ expectTypeOf>().toEqualTypeOf<
+ "body" | "createdAt" | "id" | "title" | "updatedAt" | "version"
+ >();
+ });
+ });
+
+ describe("write input", () => {
+ it("never exposes the version column", () => {
+ expectTypeOf>().not.toHaveProperty(
+ "version",
+ );
+ expectTypeOf>().not.toHaveProperty(
+ "version",
+ );
+
+ assertType>({
+ title: "Hello",
+ // @ts-expect-error - the version moves with the write, never in it
+ version: 2,
+ });
+ });
+ });
+
+ // The R1 case from the Stage 4 plan: the reserved-name check has to resolve
+ // `TEditorial` before `TFields` is checked against its constraint. The runtime
+ // `assertFieldName` guard covers a JavaScript caller either way, but this is
+ // what makes the mistake visible in the editor.
+ describe("reserved field names", () => {
+ it("rejects `version` once editorial is enabled", () => {
+ defineContentType({
+ id: "test.clash-version",
+ tableName: "test_clash_version",
+ fields: {
+ title: field.text({ required: true }),
+ // @ts-expect-error - generated by `editorial`
+ version: field.number({ integer: true, defaultValue: 0 }),
+ },
+ editorial: { enabled: true },
+ admin: { label: { plural: "Clashes", singular: "Clash" } },
+ });
+ });
+
+ it("still allows it without editorial", () => {
+ const withOwnVersion = defineContentType({
+ id: "test.own-version",
+ tableName: "test_own_version",
+ fields: {
+ title: field.text({ required: true }),
+ version: field.number({ integer: true, defaultValue: 0 }),
+ },
+ admin: { label: { plural: "Fine", singular: "Fine" } },
+ });
+
+ expectTypeOf(withOwnVersion.editorial.enabled).toEqualTypeOf();
+ // Its own declared field, so it is writable - unlike the generated column.
+ expectTypeOf<
+ ContentUpdateInput["version"]
+ >().toEqualTypeOf();
+ });
+ });
+
+ describe("admin config", () => {
+ it("accepts the generated column once enabled", () => {
+ defineContentType({
+ id: "test.version-column",
+ tableName: "test_version_column",
+ fields: { title: field.text({ required: true }) },
+ editorial: { enabled: true },
+ admin: {
+ label: { plural: "Columns", singular: "Column" },
+ list: { columns: ["title", "version"], defaultOrderBy: "version" },
+ },
+ });
+ });
+
+ it("rejects it when editorial is off", () => {
+ defineContentType({
+ id: "test.no-version-column",
+ tableName: "test_no_version_column",
+ fields: { title: field.text({ required: true }) },
+ admin: {
+ label: { plural: "Columns", singular: "Column" },
+ // @ts-expect-error - `version` is not a column of this content type
+ list: { columns: ["title", "version"] },
+ },
+ });
+ });
+ });
+
+ describe("derived type aliases", () => {
+ it("adds the generated column to the orderable union", () => {
+ expectTypeOf>().toEqualTypeOf<
+ "body" | "createdAt" | "id" | "title" | "updatedAt" | "version"
+ >();
+ });
+
+ it("leaves the union alone without editorial", () => {
+ expectTypeOf<
+ ContentOrderableFieldName
+ >().toEqualTypeOf<"createdAt" | "id" | "title" | "updatedAt">();
+ });
+ });
+});
+
+describe("the events an editorial content type emits", () => {
+ type PostEvents = ContentEventsFor;
+ type NoteEvents = ContentEventsFor;
+ type PlainEvents = ContentEventsFor;
+
+ it("adds `restored` to any editorial content type", () => {
+ expectTypeOf().toHaveProperty(
+ "content.test.editorial.restored",
+ );
+ expectTypeOf().toHaveProperty("content.test.note.restored");
+ });
+
+ it("does not add it without editorial", () => {
+ type PlainKeys = keyof PlainEvents;
+
+ expectTypeOf<"content.test.category.restored">().not.toExtend();
+ // The three every content type gets, so the assertion above is not vacuous.
+ expectTypeOf<"content.test.category.updated">().toExtend();
+ });
+
+ it("adds the schedule pair only with scheduling", () => {
+ expectTypeOf().toHaveProperty(
+ "content.test.editorial.scheduled",
+ );
+ expectTypeOf().toHaveProperty(
+ "content.test.editorial.schedule_cancelled",
+ );
+ });
+
+ it("withholds it from an editorial type that cannot schedule", () => {
+ // `test.note` has editorial but no publication, so there is no `status` to
+ // move and the keys must not exist at all.
+ type NoteKeys = keyof NoteEvents;
+
+ expectTypeOf<"content.test.note.scheduled">().not.toExtend();
+ expectTypeOf<"content.test.note.schedule_cancelled">().not.toExtend();
+ // The one it *does* get, so the assertion above is not vacuous.
+ expectTypeOf<"content.test.note.restored">().toExtend();
+ });
+
+ it("carries who booked a schedule that fired", () => {
+ expectTypeOf<
+ PostEvents["content.test.editorial.published"]["scheduledBy"]
+ >().toEqualTypeOf();
+ });
+});
diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts
index fa056e1e4..7964f01a8 100644
--- a/packages/vitnode/src/content/errors.ts
+++ b/packages/vitnode/src/content/errors.ts
@@ -1,3 +1,5 @@
+import type { ContentScheduleCode } from "./schedules";
+
/**
* Thrown while a content type definition is being built or registered - always
* at import/boot time, never per request. The message names the offending
@@ -41,3 +43,96 @@ export class ContentInputError extends ContentEngineError {
this.name = "ContentInputError";
}
}
+
+/**
+ * A write lost the race: the record moved between the read the editor started
+ * from and the write they just sent.
+ *
+ * Per-request like {@link ContentInputError}, and carries both versions rather
+ * than only a message - the AdminCP reloads the newer row and shows what
+ * changed, which it cannot do from prose. The generated routes turn it into a
+ * structured 409; nothing from the driver is in it.
+ */
+export class ContentVersionConflict extends ContentEngineError {
+ constructor({
+ contentTypeId,
+ currentVersion,
+ expectedVersion,
+ itemId,
+ }: {
+ contentTypeId: string;
+ currentVersion: number;
+ expectedVersion: number;
+ itemId: number;
+ }) {
+ super(
+ `This record is at version ${currentVersion}, not ${expectedVersion}. Someone else saved it first.`,
+ { contentTypeId },
+ );
+
+ this.name = "ContentVersionConflict";
+ this.currentVersion = currentVersion;
+ this.expectedVersion = expectedVersion;
+ this.itemId = itemId;
+ }
+
+ readonly currentVersion: number;
+ readonly expectedVersion: number;
+ readonly itemId: number;
+}
+
+/**
+ * A revision that cannot be applied to the record as it stands today.
+ *
+ * Thrown before anything is written, so a restore is all or nothing. `fields`
+ * names the content type's own fields and nothing else - never a Zod issue
+ * tree, which would leak internal paths.
+ */
+export class ContentRevisionNotRestorable extends ContentEngineError {
+ constructor({
+ contentTypeId,
+ fields,
+ revisionId,
+ }: {
+ contentTypeId: string;
+ fields: string[];
+ revisionId: number;
+ }) {
+ super(
+ `Revision ${revisionId} cannot be restored: ${fields.join(", ")} ${fields.length === 1 ? "is" : "are"} no longer valid for this content type.`,
+ { contentTypeId },
+ );
+
+ this.name = "ContentRevisionNotRestorable";
+ this.fields = fields;
+ this.revisionId = revisionId;
+ }
+
+ readonly fields: string[];
+ readonly revisionId: number;
+}
+
+/**
+ * A schedule that does not make sense: a time already past, or an unpublish
+ * that would fire before the publish it is meant to follow.
+ *
+ * Carries a `code` rather than only prose, because the AdminCP shows a
+ * different message - and points at a different field - for each one, and
+ * because the same rule runs client-side before the round trip.
+ */
+export class ContentScheduleError extends ContentEngineError {
+ constructor(
+ message: string,
+ {
+ code,
+ contentTypeId,
+ }: { code: ContentScheduleCode; contentTypeId: string },
+ ) {
+ super(message, { contentTypeId });
+
+ this.name = "ContentScheduleError";
+ this.code = code;
+ }
+
+ readonly code: ContentScheduleCode;
+}
diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts
index be4cf2794..295d84a8b 100644
--- a/packages/vitnode/src/content/events.ts
+++ b/packages/vitnode/src/content/events.ts
@@ -1,7 +1,14 @@
import type { ContentFieldName } from "./types";
export type ContentEventAction =
- "created" | "deleted" | "published" | "unpublished" | "updated";
+ | "created"
+ | "deleted"
+ | "published"
+ | "restored"
+ | "schedule_cancelled"
+ | "scheduled"
+ | "unpublished"
+ | "updated";
export interface ContentCreatedPayload {
contentId: number;
@@ -20,10 +27,82 @@ export interface ContentPublishedPayload {
contentId: number;
/** When the row was published for the *first* time; never rewritten. */
publishedAt: Date;
+ /**
+ * The person who created the schedule that fired this, when one did.
+ *
+ * Absent on an interactive publish, so no existing listener sees a new field.
+ * It is the only way to answer "the system did it, on whose instruction" -
+ * the actor of a scheduled run is genuinely the system, and inventing a user
+ * id there would be a lie in the audit trail.
+ */
+ scheduledBy?: null | number;
+ /**
+ * The booking that fired this, when one did - and the idempotency key for a
+ * listener that must act exactly once.
+ *
+ * Scheduled announcements are delivered **at least** once: they run in a
+ * queue task that retries whenever the event, the search write or a cache
+ * origin failed, and a retry re-emits an event that may already have been
+ * received. The id does not change between those attempts, so a listener that
+ * records "I have handled schedule 55" can safely ignore the second copy.
+ *
+ * Absent on an interactive publish, which is emitted once by the route that
+ * performed it and has no booking to point at.
+ */
+ scheduleId?: number;
}
export interface ContentUnpublishedPayload {
contentId: number;
+ /** As on `published`: who scheduled it, when a schedule fired it. */
+ scheduledBy?: null | number;
+ /** As on `published`: the booking, and the idempotency key for retries. */
+ scheduleId?: number;
+}
+
+/**
+ * A record was rolled back to the field values of an earlier revision.
+ *
+ * Emitted **instead of** `updated`, not alongside it - the one-event-per-mutation
+ * rule below holds here too, and a listener that fired twice would do every
+ * piece of downstream work twice. `changedFields` is carried for exactly that
+ * reason: porting an `updated` listener is a rename, not a rewrite.
+ *
+ * There is deliberately no publication field. A restore never moves `status` or
+ * `publishedAt`, so anyone listening for a visibility change still only has to
+ * watch `published` and `unpublished`.
+ */
+export interface ContentRestoredPayload {
+ changedFields: ContentFieldName[];
+ contentId: number;
+ /** The revision the values came from. */
+ restoredFromRevisionId: number;
+ /** The revision this restore itself created. */
+ revisionId: number;
+ version: number;
+}
+
+/**
+ * A transition was booked for later, or the booking was called off.
+ *
+ * These are **not** revisions and consume no version: scheduling changes no
+ * field value. When the schedule actually fires, the resulting transition emits
+ * the ordinary `published`/`unpublished` event with `scheduledBy` set.
+ */
+export interface ContentScheduledPayload {
+ action: "publish" | "unpublish";
+ /** The staff member who booked it. */
+ actorUserId: null | number;
+ contentId: number;
+ scheduledFor: Date;
+ scheduleId: number;
+}
+
+export interface ContentScheduleCancelledPayload {
+ action: "publish" | "unpublish";
+ actorUserId: null | number;
+ contentId: number;
+ scheduleId: number;
}
/**
@@ -46,6 +125,30 @@ type ContentPublicationEventsFor =
>
: Record;
+/**
+ * The extra event the editorial workflow adds.
+ *
+ * Gated the same way the publication pair is, so a content type without
+ * `editorial` gains no key at all and a listener for one cannot be registered.
+ */
+type ContentEditorialEventsFor =
+ (TDefinition extends { editorial: { enabled: true } }
+ ? Record<
+ `content.${TDefinition["id"]}.restored`,
+ ContentRestoredPayload
+ >
+ : Record) &
+ (TDefinition extends { editorial: { scheduling: { enabled: true } } }
+ ? Record<
+ `content.${TDefinition["id"]}.schedule_cancelled`,
+ ContentScheduleCancelledPayload
+ > &
+ Record<
+ `content.${TDefinition["id"]}.scheduled`,
+ ContentScheduledPayload
+ >
+ : Record);
+
/**
* The events a content type emits, as a literal-keyed map.
*
@@ -65,7 +168,8 @@ type ContentPublicationEventsFor =
* payloads stay minimal.
*/
export type ContentEventsFor =
- ContentPublicationEventsFor &
+ ContentEditorialEventsFor &
+ ContentPublicationEventsFor &
Record<`content.${TDefinition["id"]}.created`, ContentCreatedPayload> &
Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> &
Record<
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index 679aae900..4ddfab80f 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -33,14 +33,37 @@ export {
contentPublicSlugTag,
isContentPubliclyVisible,
} from "./cache";
-export type { ContentInvalidationInput } from "./cache";
+export type {
+ ContentInvalidationInput,
+ ContentInvalidationMode,
+} from "./cache";
+export {
+ parseContentConflict,
+ parseContentUnprocessable,
+ zodContentConflict,
+ zodContentUnprocessable,
+} from "./conflicts";
+export type {
+ ContentConflict,
+ ContentConflictCode,
+ ContentUnprocessable,
+ ContentUnprocessableCode,
+} from "./conflicts";
export {
+ CONTENT_ACTOR_TYPES,
CONTENT_CACHE_TAG_MAX_LENGTH,
+ CONTENT_CONFLICT_CODES,
CONTENT_DEFAULT_PAGE_SIZE,
+ CONTENT_EDITORIAL_FIELDS,
CONTENT_ENUM_DEFAULT_LENGTH,
CONTENT_FILTERABLE_FIELD_KINDS,
CONTENT_OPTIONS_LIMIT,
CONTENT_PERMISSIONS,
+ CONTENT_PREVIEW_DEFAULT_TTL_MINUTES,
+ CONTENT_PREVIEW_MAX_TTL_MINUTES,
+ CONTENT_PREVIEW_MIN_TTL_MINUTES,
+ CONTENT_PREVIEW_PATH_MAX_LENGTH,
+ CONTENT_PREVIEW_TOKEN_PLACEHOLDER,
CONTENT_PUBLIC_ALWAYS_ORDERABLE,
CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
CONTENT_PUBLIC_EXPOSABLE_COLUMNS,
@@ -52,6 +75,11 @@ export {
CONTENT_PUBLICATION_FIELDS,
CONTENT_PUBLICATION_STATUS_LENGTH,
CONTENT_PUBLICATION_STATUSES,
+ CONTENT_REVISION_DEFAULT_RETENTION,
+ CONTENT_REVISION_MAX_RETENTION,
+ CONTENT_REVISION_MIN_RETENTION,
+ CONTENT_REVISION_OPERATIONS,
+ CONTENT_REVISION_SNAPSHOT_VERSION,
CONTENT_SEARCH_DESCRIPTION_KINDS,
CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH,
CONTENT_SEARCH_PATH_MAX_LENGTH,
@@ -61,11 +89,17 @@ export {
CONTENT_SLUG_DEFAULT_LENGTH,
CONTENT_SYSTEM_FIELDS,
CONTENT_TEXT_DEFAULT_LENGTH,
+ CONTENT_UNPROCESSABLE_CODES,
isContentPublicationStatus,
RESERVED_FILTER_KEYS,
} from "./const";
export { defineContentType } from "./define";
-export { ContentEngineError, ContentInputError } from "./errors";
+export {
+ ContentEngineError,
+ ContentInputError,
+ ContentRevisionNotRestorable,
+ ContentVersionConflict,
+} from "./errors";
export { contentEventName } from "./events";
export type {
ContentCreatedPayload,
@@ -91,6 +125,24 @@ export {
withContentPermissions,
} from "./registry";
export type { RegisteredContentType } from "./registry";
+export { contentRevisionDiff } from "./revisions";
+export type {
+ ContentActor,
+ ContentActorType,
+ ContentRevisionDetail,
+ ContentRevisionDiffEntry,
+ ContentRevisionMeta,
+ ContentRevisionOperation,
+ ContentRevisionSnapshot,
+ ContentSnapshotValue,
+} from "./revisions";
+export { contentScheduleTimingError } from "./schedules";
+export type {
+ ContentSchedule,
+ ContentScheduleAction,
+ ContentScheduleCode,
+ ContentScheduleStatus,
+} from "./schedules";
export { buildContentSchemas } from "./schemas";
export type { ContentSchemas } from "./schemas";
export {
@@ -107,6 +159,12 @@ export type {
ContentBooleanField,
ContentCreateInput,
ContentDateTimeField,
+ ContentEditorialConfig,
+ ContentEditorialEnabled,
+ ContentEditorialField,
+ ContentEditorialPreviewConfig,
+ ContentEditorialRevisionsConfig,
+ ContentEditorialSchedulingConfig,
ContentEnumField,
ContentFieldDescriptor,
ContentFieldInput,
@@ -120,6 +178,7 @@ export type {
ContentNumberField,
ContentOnDelete,
ContentOrderableFieldName,
+ ContentPreviewEnabled,
ContentPublicApiConfig,
ContentPublicationConfig,
ContentPublicationField,
@@ -134,6 +193,7 @@ export type {
ContentReferenceField,
ContentReferenceFieldName,
ContentRelationField,
+ ContentSchedulingEnabled,
ContentSearchConfig,
ContentSearchDescriptionField,
ContentSearchTextField,
@@ -147,12 +207,16 @@ export type {
ContentTypeDefinition,
ContentUpdateInput,
ContentUserField,
+ EditorialContentTypeDefinition,
FilterableContentFieldKind,
FilterableContentFieldName,
+ PreviewableContentTypeDefinition,
ResolvedContentAdminConfig,
+ ResolvedContentEditorialConfig,
ResolvedContentIndex,
ResolvedContentPublicApiConfig,
ResolvedContentPublicationConfig,
ResolvedContentSearchConfig,
+ SchedulableContentTypeDefinition,
SearchableContentTypeDefinition,
} from "./types";
diff --git a/packages/vitnode/src/content/next/fetch.server.ts b/packages/vitnode/src/content/next/fetch.server.ts
index ac437025d..d17225d1f 100644
--- a/packages/vitnode/src/content/next/fetch.server.ts
+++ b/packages/vitnode/src/content/next/fetch.server.ts
@@ -3,6 +3,7 @@ import type { z } from "zod";
import type {
AnyContentTypeDefinition,
+ PreviewableContentTypeDefinition,
PublicContentTypeDefinition,
} from "../types";
@@ -91,6 +92,64 @@ export const contentPublicFetch = async ({
: { status: response.status };
};
+/**
+ * Reads a record through a preview link, from a server component.
+ *
+ * The mirror image of {@link contentPublicFetch}, and deliberately so: this one
+ * opts *out* of the cache and carries no tags at all.
+ *
+ * - **`cache: "no-store"`.** A preview is an unpublished record behind a
+ * short-lived credential. Storing one would keep a draft readable after the
+ * token expired, and would serve one reviewer's link to the next visitor.
+ * - **No tags.** There is nothing to invalidate: the response was never stored,
+ * and a preview is a point-in-time read of one frozen revision.
+ *
+ * The route answers 404 for every kind of bad token, so a caller gets one
+ * status to handle rather than a taxonomy - `notFound()` is the whole error
+ * path.
+ *
+ * ```tsx title="src/app/articles/preview/[token]/page.tsx"
+ * const { data } = await contentPreviewFetch({
+ * definition: articleContentType,
+ * pluginId: "@vitnode/example",
+ * token: (await params).token,
+ * });
+ * if (!data) notFound();
+ * ```
+ */
+export const contentPreviewFetch = async ({
+ definition,
+ pluginId,
+ schema,
+ token,
+}: {
+ definition: PreviewableContentTypeDefinition;
+ pluginId: string;
+ schema?: TSchema;
+ token: string;
+}): Promise>> => {
+ const response = await rawApiFetch({
+ method: "get",
+ module: `content/${definition.publicApi.path}`,
+ options: { cache: "no-store" },
+ path: `/preview/${encodeURIComponent(token)}`,
+ pluginId,
+ });
+
+ if (!response.ok) return { status: response.status };
+
+ const payload: unknown = await response.json();
+ if (!schema) {
+ return { data: payload as z.infer, status: response.status };
+ }
+
+ const parsed = schema.safeParse(payload);
+
+ return parsed.success
+ ? { data: parsed.data, status: response.status }
+ : { status: response.status };
+};
+
/** The tag a detail response keyed by identifier should carry. */
export const contentPublicItemTags = (
definition: AnyContentTypeDefinition,
diff --git a/packages/vitnode/src/content/next/index.ts b/packages/vitnode/src/content/next/index.ts
index f01f56e7e..c918315b6 100644
--- a/packages/vitnode/src/content/next/index.ts
+++ b/packages/vitnode/src/content/next/index.ts
@@ -8,7 +8,15 @@
*
* The cache *tags* live in `@vitnode/core/content`, because they are strings.
*/
-export { contentPublicFetch, contentPublicItemTags } from "./fetch.server";
+export {
+ contentPreviewFetch,
+ contentPublicFetch,
+ contentPublicItemTags,
+} from "./fetch.server";
export type { ContentPublicFetchResult } from "./fetch.server";
+export { POST as contentRevalidateRoute } from "./revalidate-route.server";
export { revalidateContent } from "./revalidate.server";
-export type { ContentInvalidationMode } from "./revalidate.server";
+export type {
+ ContentInvalidationContext,
+ ContentInvalidationMode,
+} from "./revalidate.server";
diff --git a/packages/vitnode/src/content/next/revalidate-route.server.test.ts b/packages/vitnode/src/content/next/revalidate-route.server.test.ts
new file mode 100644
index 000000000..bbfadac94
--- /dev/null
+++ b/packages/vitnode/src/content/next/revalidate-route.server.test.ts
@@ -0,0 +1,136 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { CONTENT_REVALIDATE_TIMESTAMP_HEADER } from "../server/revalidate-bridge";
+
+interface CacheCall {
+ profile?: unknown;
+ tag: string;
+}
+
+const calls = vi.hoisted(() => [] as CacheCall[]);
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("next/cache", () => ({
+ revalidateTag: (tag: string, profile: unknown) => {
+ calls.push({ profile, tag });
+ },
+ updateTag: () => {
+ throw new Error("updateTag is Server-Action-only");
+ },
+}));
+
+const { POST } = await import("./revalidate-route.server");
+
+const SECRET = "shared-secret";
+
+const body = {
+ contentTypeId: "example.article",
+ id: 7,
+ isPublic: true,
+ mode: "immediate" as const,
+ slugs: ["hello-world"],
+ wasPublic: false,
+};
+
+const request = (overrides?: {
+ body?: string;
+ secret?: string;
+ timestamp?: number | string;
+}) =>
+ new Request("https://web.example.com/api/vitnode/content/revalidate", {
+ body: overrides?.body ?? JSON.stringify(body),
+ headers: {
+ authorization: `Bearer ${overrides?.secret ?? SECRET}`,
+ "content-type": "application/json",
+ [CONTENT_REVALIDATE_TIMESTAMP_HEADER]: String(
+ overrides?.timestamp ?? Date.now(),
+ ),
+ },
+ method: "POST",
+ });
+
+beforeEach(() => {
+ calls.length = 0;
+ vi.stubEnv("CRON_SECRET", SECRET);
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("the revalidation Route Handler", () => {
+ it("expires the tags a valid request names", async () => {
+ const response = await POST(request());
+
+ expect(response.status).toBe(200);
+ expect(calls.length).toBeGreaterThan(0);
+ // `updateTag` throws in this mock, so reaching here at all proves the
+ // handler picked the Route-Handler path.
+ expect(calls.every(call => call.profile !== undefined)).toBe(true);
+ });
+
+ it("expires the list, the item and the slug", async () => {
+ await POST(request());
+
+ expect(calls.map(call => call.tag).sort()).toEqual([
+ "content:example.article:item:7",
+ "content:example.article:list",
+ "content:example.article:slug:hello-world",
+ ]);
+ });
+
+ it("honours stale-while-revalidate", async () => {
+ await POST(
+ request({
+ body: JSON.stringify({ ...body, mode: "stale-while-revalidate" }),
+ }),
+ );
+
+ expect(calls[0].profile).toBe("max");
+ });
+
+ it.each([
+ ["the wrong secret", { secret: "not-the-secret" }],
+ ["a secret of a different length", { secret: "short" }],
+ ])("refuses %s", async (_name, overrides) => {
+ const response = await POST(request(overrides));
+
+ expect(response.status).toBe(403);
+ expect(calls).toHaveLength(0);
+ });
+
+ it("refuses a missing bearer token", async () => {
+ const response = await POST(
+ new Request("https://web.example.com/x", {
+ body: JSON.stringify(body),
+ method: "POST",
+ }),
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it.each([
+ ["stale", Date.now() - 10 * 60 * 1000],
+ ["from the future", Date.now() + 10 * 60 * 1000],
+ ["not a number", "yesterday"],
+ ])("refuses a timestamp that is %s", async (_name, timestamp) => {
+ const response = await POST(request({ timestamp }));
+
+ expect(response.status).toBe(403);
+ expect(calls).toHaveLength(0);
+ });
+
+ it.each([
+ ["not JSON", "not json at all"],
+ ["the wrong shape", JSON.stringify({ nope: true })],
+ ["a bad mode", JSON.stringify({ ...body, mode: "eventually" })],
+ ])("answers 400 for a body that is %s", async (_name, payload) => {
+ const response = await POST(request({ body: payload }));
+
+ expect(response.status).toBe(400);
+ expect(calls).toHaveLength(0);
+ });
+});
diff --git a/packages/vitnode/src/content/next/revalidate-route.server.ts b/packages/vitnode/src/content/next/revalidate-route.server.ts
new file mode 100644
index 000000000..2a22e6b28
--- /dev/null
+++ b/packages/vitnode/src/content/next/revalidate-route.server.ts
@@ -0,0 +1,89 @@
+import "server-only";
+import crypto from "node:crypto";
+import { z } from "zod";
+
+import { CONFIG } from "../../lib/config";
+import {
+ CONTENT_REVALIDATE_MAX_SKEW_MS,
+ CONTENT_REVALIDATE_TIMESTAMP_HEADER,
+} from "../server/revalidate-bridge";
+import { revalidateContent } from "./revalidate.server";
+
+const zodBody = z.object({
+ contentTypeId: z.string().min(1),
+ id: z.number().int().positive(),
+ isPublic: z.boolean(),
+ mode: z.enum(["immediate", "stale-while-revalidate"]),
+ slugs: z.array(z.string()),
+ wasPublic: z.boolean(),
+});
+
+const matches = (provided: string, expected: string): boolean => {
+ const a = Buffer.from(provided, "utf8");
+ const b = Buffer.from(expected, "utf8");
+
+ // `timingSafeEqual` throws on a length mismatch, so the length check is not
+ // optional. It leaks the length of a secret and nothing else.
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+};
+
+/**
+ * The web-side half of the background cache bridge.
+ *
+ * Mount it once per app:
+ *
+ * ```ts title="src/app/api/vitnode/content/revalidate/route.ts"
+ * export { POST } from "@vitnode/core/content/next/revalidate-route";
+ * ```
+ *
+ * It exists because the API process cannot call `next/cache`. When a scheduled
+ * publish makes a record public, *something* has to expire the tags in the
+ * process that owns the cache - and this is the smallest thing that can.
+ *
+ * Deliberately narrow. It takes one shape, it calls one function, and the worst
+ * a valid request can do is expire a cache tag. It is not an events endpoint
+ * and must not grow into one.
+ */
+export const POST = async (request: Request): Promise => {
+ const secret = CONFIG.cronJobSecret;
+ const authorization = request.headers.get("authorization") ?? "";
+ const provided = authorization.startsWith("Bearer ")
+ ? authorization.slice("Bearer ".length)
+ : "";
+
+ if (!provided || !matches(provided, secret)) {
+ return Response.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // Replaying a revalidation only expires a tag again, so a window is
+ // proportionate - a nonce store would be a database table to guard nothing.
+ const timestamp = Number(
+ request.headers.get(CONTENT_REVALIDATE_TIMESTAMP_HEADER),
+ );
+ if (
+ !Number.isFinite(timestamp) ||
+ Math.abs(Date.now() - timestamp) > CONTENT_REVALIDATE_MAX_SKEW_MS
+ ) {
+ return Response.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ const parsed = zodBody.safeParse(payload);
+ if (!parsed.success) {
+ return Response.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ const { mode, ...input } = parsed.data;
+
+ // `route-handler`, truthfully: `updateTag` throws here, and saying otherwise
+ // would turn every background publish into a 500.
+ revalidateContent(input, { context: "route-handler", mode });
+
+ return Response.json({ ok: true });
+};
diff --git a/packages/vitnode/src/content/next/revalidate.server.test.ts b/packages/vitnode/src/content/next/revalidate.server.test.ts
index cbf537078..825b9afd0 100644
--- a/packages/vitnode/src/content/next/revalidate.server.test.ts
+++ b/packages/vitnode/src/content/next/revalidate.server.test.ts
@@ -88,3 +88,48 @@ describe("what it touches", () => {
expect(calls.map(call => call.tag)).toContain("content:test.post:slug:new");
});
});
+
+describe("context", () => {
+ it("uses updateTag from a Server Action, for read-your-own-writes", () => {
+ revalidateContent(published, {
+ context: "server-action",
+ mode: "immediate",
+ });
+
+ expect(functionsCalled()).toEqual(["updateTag"]);
+ });
+
+ it("expires with `expire: 0` from a Route Handler", () => {
+ // `updateTag` throws outside a Server Action, so the background cache
+ // bridge - which lands in a Route Handler - would turn every scheduled
+ // publish into a 500 if it used the default.
+ revalidateContent(published, {
+ context: "route-handler",
+ mode: "immediate",
+ });
+
+ expect(functionsCalled()).toEqual(["revalidateTag"]);
+ expect(calls.every(call => call.profile !== undefined)).toBe(true);
+ expect(calls[0].profile).toEqual({ expire: 0 });
+ });
+
+ it("leaves stale-while-revalidate alone in either context", () => {
+ // SWR already works everywhere, so the context changes nothing.
+ for (const context of ["route-handler", "server-action"] as const) {
+ calls.length = 0;
+ revalidateContent(published, {
+ context,
+ mode: "stale-while-revalidate",
+ });
+
+ expect(functionsCalled()).toEqual(["revalidateTag"]);
+ expect(calls[0].profile).toBe("max");
+ }
+ });
+
+ it("defaults to server-action, so nothing existing changed", () => {
+ revalidateContent(published, { mode: "immediate" });
+
+ expect(functionsCalled()).toEqual(["updateTag"]);
+ });
+});
diff --git a/packages/vitnode/src/content/next/revalidate.server.ts b/packages/vitnode/src/content/next/revalidate.server.ts
index 9ac72f961..c766c9b95 100644
--- a/packages/vitnode/src/content/next/revalidate.server.ts
+++ b/packages/vitnode/src/content/next/revalidate.server.ts
@@ -1,21 +1,25 @@
import "server-only";
import { revalidateTag, updateTag } from "next/cache";
-import type { ContentInvalidationInput } from "../cache";
+import type {
+ ContentInvalidationInput,
+ ContentInvalidationMode,
+} from "../cache";
import { contentInvalidationTags } from "../cache";
+export type { ContentInvalidationMode };
+
/**
- * How hard a mutation expires the tags it touched.
+ * Where the call is coming from, which decides *how* `immediate` is done.
*
- * - `immediate` - `updateTag`. The next request waits for fresh data; no stale
- * response is served at all. **Server Actions only**, which is where every
- * generated write path already lives.
- * - `stale-while-revalidate` - `revalidateTag(tag, "max")`. The cached response
- * is served once more while the new one is fetched behind it. Cheaper, and
- * callable from a Route Handler.
+ * `updateTag` buys read-your-own-writes and is Server-Action-only. A Route
+ * Handler cannot call it - but `revalidateTag(tag, { expire: 0 })` expires a
+ * tag immediately there, which is the documented path for a webhook. Same
+ * guarantee for the next reader either way, so the caller names its context and
+ * gets the strongest option available to it.
*/
-export type ContentInvalidationMode = "immediate" | "stale-while-revalidate";
+export type ContentInvalidationContext = "route-handler" | "server-action";
/**
* Expires the public cache entries one mutation actually affected.
@@ -39,22 +43,35 @@ export type ContentInvalidationMode = "immediate" | "stale-while-revalidate";
* still-reachable page says; that response is safe to serve once more, and
* keeping the cache warm is worth more than a few seconds of freshness.
*
- * @throws if `immediate` is used outside a Server Action - `updateTag` is
- * Server-Action-only. From a Route Handler or a webhook, pass
- * `stale-while-revalidate`.
+ * `context` defaults to `server-action`, which is where every generated write
+ * path already lives. Background work reaches this through the
+ * [revalidation bridge](../server/revalidate-bridge.ts), which lands in a Route
+ * Handler and says so.
*/
export const revalidateContent = (
input: ContentInvalidationInput,
- options?: { mode?: ContentInvalidationMode },
+ options?: {
+ context?: ContentInvalidationContext;
+ mode?: ContentInvalidationMode;
+ },
): void => {
const mode = options?.mode ?? "immediate";
+ const context = options?.context ?? "server-action";
for (const tag of contentInvalidationTags(input)) {
- if (mode === "immediate") {
+ if (mode !== "immediate") {
+ revalidateTag(tag, "max");
+ continue;
+ }
+
+ if (context === "server-action") {
updateTag(tag);
continue;
}
- revalidateTag(tag, "max");
+ // `updateTag` throws outside a Server Action. `expire: 0` is the documented
+ // equivalent for a webhook: the entry is expired now rather than served
+ // stale once more.
+ revalidateTag(tag, { expire: 0 });
}
};
diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts
index a6ea9bfe7..dcfadce95 100644
--- a/packages/vitnode/src/content/registry.ts
+++ b/packages/vitnode/src/content/registry.ts
@@ -6,6 +6,7 @@ import type {
import type { AnyContentTypeDefinition } from "./types";
import {
+ CONTENT_EDITORIAL_FIELDS,
CONTENT_PERMISSIONS,
CONTENT_PUBLICATION_FIELDS,
CONTENT_SYSTEM_FIELDS,
@@ -197,6 +198,18 @@ export const contentPermissionEntries = (
},
]
: []),
+ // Restoring is the one generated operation that rewrites many fields at once
+ // from a source the editor did not type, so it gets its own gate. It depends
+ // on `can_edit` rather than `can_view`: somebody who may not edit must not
+ // reach the same outcome through the history.
+ ...(definition?.editorial.enabled
+ ? [
+ {
+ dependsOn: [CONTENT_PERMISSIONS.edit],
+ permission: CONTENT_PERMISSIONS.restore,
+ },
+ ]
+ : []),
];
/**
@@ -231,6 +244,7 @@ export const orderableColumns = (
...definition.admin.list.orderableFields,
...CONTENT_SYSTEM_FIELDS,
...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []),
+ ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []),
];
/**
diff --git a/packages/vitnode/src/content/revisions.test.ts b/packages/vitnode/src/content/revisions.test.ts
new file mode 100644
index 000000000..878486c12
--- /dev/null
+++ b/packages/vitnode/src/content/revisions.test.ts
@@ -0,0 +1,90 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import type { ContentRevisionSnapshot } from "./revisions";
+
+import { contentRevisionDiff } from "./revisions";
+
+const snapshot = (
+ fields: ContentRevisionSnapshot["fields"],
+): ContentRevisionSnapshot => ({
+ contentTypeId: "test.editorial",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ fields,
+ id: 1,
+ schemaVersion: 1,
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ version: 1,
+});
+
+const names = ["title", "excerpt", "views", "featured", "publishedOn"];
+
+describe("contentRevisionDiff", () => {
+ it("reports only the fields that moved", () => {
+ const before = snapshot({ excerpt: "Old", title: "Hello", views: 1 });
+ const after = snapshot({ excerpt: "Old", title: "Goodbye", views: 2 });
+
+ expect(contentRevisionDiff(names, before, after)).toEqual([
+ { after: "Goodbye", before: "Hello", name: "title" },
+ { after: 2, before: 1, name: "views" },
+ ]);
+ });
+
+ it("keeps the content type's declaration order", () => {
+ const before = snapshot({ excerpt: "a", title: "a", views: 1 });
+ const after = snapshot({ excerpt: "b", title: "b", views: 2 });
+
+ expect(contentRevisionDiff(names, before, after).map(e => e.name)).toEqual([
+ "title",
+ "excerpt",
+ "views",
+ ]);
+ });
+
+ it("distinguishes an explicit null from an absent field", () => {
+ const before = snapshot({ excerpt: "Old", title: "Hello" });
+ const after = snapshot({ excerpt: null, title: "Hello" });
+
+ expect(contentRevisionDiff(names, before, after)).toEqual([
+ { after: null, before: "Old", name: "excerpt" },
+ ]);
+ });
+
+ it("ignores a field neither snapshot carries", () => {
+ const before = snapshot({ title: "Hello" });
+ const after = snapshot({ title: "Hello" });
+
+ expect(contentRevisionDiff(names, before, after)).toEqual([]);
+ });
+
+ it("skips a field the content type no longer declares", () => {
+ // Present in both snapshots, absent from `names` - it is history, not a
+ // change, and showing it would invite a restore that cannot happen.
+ const before = snapshot({ sinceRemoved: "a", title: "Hello" });
+ const after = snapshot({ sinceRemoved: "b", title: "Hello" });
+
+ expect(contentRevisionDiff(names, before, after)).toEqual([]);
+ });
+
+ it("treats a create as every field being new", () => {
+ const after = snapshot({ excerpt: null, title: "Hello", views: 0 });
+
+ // No previous revision, so nothing is compared away.
+ expect(contentRevisionDiff(names, null, after)).toEqual([
+ { after: "Hello", before: undefined, name: "title" },
+ { after: null, before: undefined, name: "excerpt" },
+ { after: 0, before: undefined, name: "views" },
+ { after: undefined, before: undefined, name: "featured" },
+ { after: undefined, before: undefined, name: "publishedOn" },
+ ]);
+ });
+
+ it("handles booleans and zero without treating them as absent", () => {
+ const before = snapshot({ featured: true, views: 0 });
+ const after = snapshot({ featured: false, views: 0 });
+
+ expect(contentRevisionDiff(names, before, after)).toEqual([
+ { after: false, before: true, name: "featured" },
+ ]);
+ });
+});
diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts
new file mode 100644
index 000000000..a9b02057c
--- /dev/null
+++ b/packages/vitnode/src/content/revisions.ts
@@ -0,0 +1,109 @@
+import type { CONTENT_ACTOR_TYPES, CONTENT_REVISION_OPERATIONS } from "./const";
+
+export type ContentRevisionOperation =
+ (typeof CONTENT_REVISION_OPERATIONS)[number];
+
+export type ContentActorType = (typeof CONTENT_ACTOR_TYPES)[number];
+
+/**
+ * Who performed a mutation.
+ *
+ * A plain value object, not something read off a request: the editorial service
+ * takes one as an argument, so a route builds it from the Hono context and a
+ * queue handler hands over `{ type: "system", userId: null }` without either of
+ * them depending on the other's world.
+ */
+export interface ContentActor {
+ type: ContentActorType;
+ userId: null | number;
+}
+
+/**
+ * A value as it is stored in a snapshot.
+ *
+ * Deliberately narrow: a `Date` becomes an ISO string, a relation or user
+ * becomes the foreign key it already is, and nothing else survives. There is no
+ * runtime class instance in a snapshot, so re-reading one years later needs
+ * nothing but `JSON.parse`.
+ */
+export type ContentSnapshotValue = boolean | null | number | string;
+
+/**
+ * The complete post-mutation editable state of one record.
+ *
+ * Complete rather than a patch: restoring from a patch means replaying every
+ * revision since, which turns a single read into a fold that gets slower the
+ * longer the history is - and produces nothing if one link was pruned.
+ *
+ * What is deliberately absent is as important as what is here. No relation
+ * *labels* (they are administrative metadata belonging to another content type,
+ * which may not publish them at all), no search document, no cache tags,
+ * nothing derived.
+ */
+export interface ContentRevisionSnapshot {
+ contentTypeId: string;
+ createdAt: string;
+ /** Every declared field, by name. */
+ fields: Record;
+ id: number;
+ /** Present only for a content type with the publication lifecycle. */
+ publication?: { publishedAt: null | string; status: string };
+ schemaVersion: number;
+ updatedAt: string;
+ version: number;
+}
+
+/** One revision as the history list shows it - metadata, never the snapshot. */
+export interface ContentRevisionMeta {
+ /** Display name of the actor, or `null` for a system mutation. */
+ actorName: null | string;
+ actorType: ContentActorType;
+ actorUserId: null | number;
+ changedFields: string[];
+ createdAt: Date | string;
+ id: number;
+ operation: ContentRevisionOperation;
+ restoredFromRevisionId: null | number;
+ version: number;
+}
+
+/** One revision with its snapshot, loaded on demand. */
+export interface ContentRevisionDetail extends ContentRevisionMeta {
+ snapshot: ContentRevisionSnapshot;
+}
+
+export interface ContentRevisionDiffEntry {
+ after: ContentSnapshotValue | undefined;
+ before: ContentSnapshotValue | undefined;
+ name: string;
+}
+
+/**
+ * Field-level difference between two snapshots, in declaration order.
+ *
+ * Walks `names` - the content type's *current* field list - rather than the
+ * union of both snapshots' keys, so a field that has since been removed does
+ * not show up as "changed to nothing". The same projection the restore path
+ * applies, and for the same reason.
+ *
+ * `undefined` on either side means "this snapshot never carried the field",
+ * which the UI renders differently from an explicit `null`.
+ */
+export const contentRevisionDiff = (
+ names: readonly string[],
+ before: ContentRevisionSnapshot | null,
+ after: ContentRevisionSnapshot,
+): ContentRevisionDiffEntry[] => {
+ const entries: ContentRevisionDiffEntry[] = [];
+
+ for (const name of names) {
+ const previous = before?.fields[name];
+ const next = after.fields[name];
+
+ if (before !== null && previous === next) continue;
+
+ entries.push({ after: next, before: previous, name });
+ }
+
+ return entries;
+};
diff --git a/packages/vitnode/src/content/schedules.test.ts b/packages/vitnode/src/content/schedules.test.ts
new file mode 100644
index 000000000..9f777240b
--- /dev/null
+++ b/packages/vitnode/src/content/schedules.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+
+import type { ContentScheduleTimingInput } from "./schedules";
+
+import { CONTENT_SCHEDULE_PAST_TOLERANCE_MS } from "./const";
+import { contentScheduleTimingError } from "./schedules";
+
+const NOW = new Date("2026-08-05T10:00:00.000Z");
+
+const check = (overrides: Partial) =>
+ contentScheduleTimingError({
+ action: "publish",
+ now: NOW,
+ pending: [],
+ scheduledFor: new Date("2026-08-05T12:00:00.000Z"),
+ ...overrides,
+ });
+
+describe("contentScheduleTimingError", () => {
+ it("accepts a future time", () => {
+ expect(check({})).toBeNull();
+ });
+
+ it("rejects a time well in the past", () => {
+ expect(check({ scheduledFor: new Date("2026-08-05T09:00:00.000Z") })).toBe(
+ "CONTENT_SCHEDULE_IN_PAST",
+ );
+ });
+
+ it("accepts a time just barely in the past", () => {
+ // A browser clock a minute behind the server is ordinary, and one cron tick
+ // is a minute wide - "now" is what the editor meant, so it is accepted and
+ // fires on the next tick.
+ expect(
+ check({
+ scheduledFor: new Date(
+ NOW.getTime() - CONTENT_SCHEDULE_PAST_TOLERANCE_MS + 1000,
+ ),
+ }),
+ ).toBeNull();
+ });
+
+ it("rejects one just outside the tolerance", () => {
+ expect(
+ check({
+ scheduledFor: new Date(
+ NOW.getTime() - CONTENT_SCHEDULE_PAST_TOLERANCE_MS - 1000,
+ ),
+ }),
+ ).toBe("CONTENT_SCHEDULE_IN_PAST");
+ });
+
+ it("rejects an invalid date rather than passing it to the server", () => {
+ expect(check({ scheduledFor: new Date("nonsense") })).toBe(
+ "CONTENT_SCHEDULE_IN_PAST",
+ );
+ });
+
+ describe("ordering against a pending publish", () => {
+ const pending = [
+ {
+ action: "publish" as const,
+ scheduledFor: "2026-08-05T12:00:00.000Z",
+ },
+ ];
+
+ it("accepts an unpublish after it", () => {
+ expect(
+ check({
+ action: "unpublish",
+ pending,
+ scheduledFor: new Date("2026-08-05T13:00:00.000Z"),
+ }),
+ ).toBeNull();
+ });
+
+ it("rejects an unpublish before it", () => {
+ // It would fire against a draft, no-op, and then the record would go live
+ // afterwards - the opposite of what was asked for.
+ expect(
+ check({
+ action: "unpublish",
+ pending,
+ scheduledFor: new Date("2026-08-05T11:00:00.000Z"),
+ }),
+ ).toBe("CONTENT_SCHEDULE_ORDER");
+ });
+
+ it("rejects an unpublish at exactly the same moment", () => {
+ // Same tick, undefined order. Refusing is the only honest answer.
+ expect(
+ check({
+ action: "unpublish",
+ pending,
+ scheduledFor: new Date("2026-08-05T12:00:00.000Z"),
+ }),
+ ).toBe("CONTENT_SCHEDULE_ORDER");
+ });
+
+ it("does not constrain a publish", () => {
+ // Rescheduling the publish itself is not ordered against its own
+ // predecessor - the old row is about to be cancelled.
+ expect(
+ check({ pending, scheduledFor: new Date("2026-08-05T11:00:00.000Z") }),
+ ).toBeNull();
+ });
+
+ it("does not constrain an unpublish when no publish is pending", () => {
+ expect(
+ check({
+ action: "unpublish",
+ pending: [],
+ scheduledFor: new Date("2026-08-05T11:00:00.000Z"),
+ }),
+ ).toBeNull();
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/schedules.ts b/packages/vitnode/src/content/schedules.ts
new file mode 100644
index 000000000..8959ee612
--- /dev/null
+++ b/packages/vitnode/src/content/schedules.ts
@@ -0,0 +1,85 @@
+import type {
+ CONTENT_SCHEDULE_ACTIONS,
+ CONTENT_SCHEDULE_CODES,
+ CONTENT_SCHEDULE_STATUSES,
+} from "./const";
+
+import { CONTENT_SCHEDULE_PAST_TOLERANCE_MS } from "./const";
+
+export type ContentScheduleAction = (typeof CONTENT_SCHEDULE_ACTIONS)[number];
+
+export type ContentScheduleStatus = (typeof CONTENT_SCHEDULE_STATUSES)[number];
+
+export type ContentScheduleCode =
+ (typeof CONTENT_SCHEDULE_CODES)[keyof typeof CONTENT_SCHEDULE_CODES];
+
+/** One schedule, as the AdminCP and the API both see it. */
+export interface ContentSchedule {
+ action: ContentScheduleAction;
+ /** Display name of the person who asked for it, when it is resolvable. */
+ actorName: null | string;
+ completedAt: Date | null | string;
+ createdAt: Date | string;
+ createdBy: null | number;
+ /**
+ * Why the announcements for a *completed* schedule have not gone out yet.
+ *
+ * A separate field from `lastError` because it means something different: the
+ * record really did publish, and what is still being retried is the event,
+ * the search write and the cache invalidation.
+ */
+ effectsError: null | string;
+ id: number;
+ lastError: null | string;
+ scheduledFor: Date | string;
+ status: ContentScheduleStatus;
+}
+
+export interface ContentScheduleTimingInput {
+ action: ContentScheduleAction;
+ now: Date;
+ /** The pending schedules already on this record, of any action. */
+ pending: { action: ContentScheduleAction; scheduledFor: Date | string }[];
+ scheduledFor: Date;
+}
+
+/**
+ * Whether a requested schedule makes sense, and why not when it does not.
+ *
+ * Pure, and shared by the client and the server on purpose: the dialog can
+ * refuse an impossible date before the round trip, and the route stays the
+ * authority - both from one function, so they cannot drift into disagreeing.
+ */
+export const contentScheduleTimingError = ({
+ action,
+ now,
+ pending,
+ scheduledFor,
+}: ContentScheduleTimingInput): ContentScheduleCode | null => {
+ if (Number.isNaN(scheduledFor.getTime())) return "CONTENT_SCHEDULE_IN_PAST";
+
+ // A browser clock a minute behind the server is ordinary, and one cron tick
+ // is a minute wide - so "just now" is accepted and fires on the next tick.
+ if (
+ scheduledFor.getTime() <
+ now.getTime() - CONTENT_SCHEDULE_PAST_TOLERANCE_MS
+ ) {
+ return "CONTENT_SCHEDULE_IN_PAST";
+ }
+
+ if (action === "unpublish") {
+ const publish = pending.find(entry => entry.action === "publish");
+
+ // Unpublishing before the publish that has not happened yet would fire
+ // against a draft, no-op, and then the record would go live afterwards -
+ // the opposite of what was asked for.
+ if (
+ publish &&
+ new Date(publish.scheduledFor).getTime() >= scheduledFor.getTime()
+ ) {
+ return "CONTENT_SCHEDULE_ORDER";
+ }
+ }
+
+ return null;
+};
diff --git a/packages/vitnode/src/content/schemas.test.ts b/packages/vitnode/src/content/schemas.test.ts
index 0832b2f06..f95ad6b36 100644
--- a/packages/vitnode/src/content/schemas.test.ts
+++ b/packages/vitnode/src/content/schemas.test.ts
@@ -141,6 +141,55 @@ describe("generated schemas", () => {
});
});
+ describe("editorial version", () => {
+ const editorial = defineContentType({
+ id: "test.schema-version",
+ tableName: "test_schema_version",
+ fields: { title: field.text({ required: true }) },
+ editorial: { enabled: true },
+ admin: { label: { plural: "Versions", singular: "Version" } },
+ });
+
+ const row = {
+ createdAt: new Date(),
+ id: 1,
+ title: "Hello world",
+ updatedAt: new Date(),
+ version: 1,
+ };
+
+ it("is part of the response once editorial is enabled", () => {
+ expect(editorial.schemas.select.safeParse(row).success).toBe(true);
+ expect(editorial.schemas.selectObject.shape.version).toBeDefined();
+ });
+
+ it("is missing from the response without it", () => {
+ expect(schemas.selectObject.shape.version).toBeUndefined();
+ });
+
+ it("is never writable", () => {
+ // Both schemas are strict, so this is a rejection rather than a strip -
+ // the version moves with the write, never in it.
+ expect(
+ editorial.schemas.create.safeParse({ title: "Hello", version: 2 })
+ .success,
+ ).toBe(false);
+ expect(
+ editorial.schemas.update.safeParse({ title: "Hello", version: 2 })
+ .success,
+ ).toBe(false);
+ });
+
+ it("is orderable once editorial is enabled", () => {
+ expect(
+ editorial.schemas.order.safeParse({ orderBy: "version" }).success,
+ ).toBe(true);
+ expect(schemas.order.safeParse({ orderBy: "version" }).success).toBe(
+ false,
+ );
+ });
+ });
+
describe("order", () => {
it("allows the declared orderable fields and the system columns", () => {
for (const orderBy of [
diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts
index 384613b05..d4b79fc6f 100644
--- a/packages/vitnode/src/content/schemas.ts
+++ b/packages/vitnode/src/content/schemas.ts
@@ -13,6 +13,7 @@ import type {
} from "./types";
import {
+ CONTENT_EDITORIAL_FIELDS,
CONTENT_PUBLIC_ALWAYS_ORDERABLE,
CONTENT_PUBLICATION_FIELDS,
CONTENT_PUBLICATION_STATUSES,
@@ -74,6 +75,18 @@ export interface ContentSchemas {
selectObject: z.ZodObject;
/** Request body for update. Every field optional, but never empty. */
update: z.ZodType>;
+ /**
+ * Request body for an editorial update: the field values, plus the version
+ * the editor started from.
+ *
+ * An envelope rather than a key inside `values`, because `update` is a strict
+ * object of *content fields* and `expectedVersion` is transport. Empty for a
+ * content type without `editorial`, whose update body stays exactly as it was.
+ */
+ updateEnvelope: z.ZodType<{
+ expectedVersion: number;
+ values: ContentUpdateInput;
+ }>;
}
const textSchema = (fieldValue: {
@@ -287,11 +300,13 @@ const publicSelectShape = (
*/
export const buildContentSchemas = ({
admin,
+ editorial = false,
fields,
publicApi = DISABLED_PUBLIC_API,
publication = false,
}: {
admin: ResolvedContentAdminConfig;
+ editorial?: boolean;
fields: ContentFieldMap;
publicApi?: ResolvedContentPublicApiConfig;
publication?: boolean;
@@ -307,6 +322,12 @@ export const buildContentSchemas = ({
}
: {};
+ // Read-only for the same reason, and returned for one: a client needs it to
+ // send `expectedVersion` back on the next write.
+ const editorialSelectShape: z.ZodRawShape = editorial
+ ? { version: z.number().int().positive() }
+ : {};
+
const selectShape: z.ZodRawShape = {
id: z.number(),
...Object.fromEntries(
@@ -316,6 +337,7 @@ export const buildContentSchemas = ({
]),
),
...publicationSelectShape,
+ ...editorialSelectShape,
createdAt: z.date(),
updatedAt: z.date(),
};
@@ -334,6 +356,7 @@ export const buildContentSchemas = ({
...admin.list.orderableFields,
...CONTENT_SYSTEM_FIELDS,
...(publication ? CONTENT_PUBLICATION_FIELDS : []),
+ ...(editorial ? CONTENT_EDITORIAL_FIELDS : []),
];
const selectObject = z.object(selectShape);
@@ -390,5 +413,14 @@ export const buildContentSchemas = ({
select: selectObject as unknown as z.ZodType>,
selectObject,
update: update as unknown as z.ZodType>,
+ updateEnvelope: z.strictObject({
+ // Positive, so a client that forgot to send one cannot coerce `0` past
+ // the guard and race the very check it is meant to lose.
+ expectedVersion: z.number().int().positive(),
+ values: update,
+ }) as unknown as z.ZodType<{
+ expectedVersion: number;
+ values: ContentUpdateInput;
+ }>,
};
};
diff --git a/packages/vitnode/src/content/server/actor.ts b/packages/vitnode/src/content/server/actor.ts
new file mode 100644
index 000000000..95af39998
--- /dev/null
+++ b/packages/vitnode/src/content/server/actor.ts
@@ -0,0 +1,30 @@
+import type { Context } from "hono";
+
+import type { ContentActor } from "../revisions";
+
+/**
+ * Who a request is acting as, for the revision it is about to write.
+ *
+ * Built by the *route*, not by the service, because only the route knows which
+ * gate it sits behind. An admin route has already been through
+ * `globalAdminMiddleware` and `assertStaffPermission`, so `c.get("admin")` is
+ * populated and the mutation is `staff`. A hand-written route that a signed-in
+ * member reached is `api`. Anything with no user at all - a cron request, the
+ * queue worker - is `system`, and gets a `null` user id rather than a fake one.
+ */
+export const resolveContentActor = (c: Context): ContentActor => {
+ const admin = c.get("admin") as null | { user?: { id?: unknown } };
+ const adminId = admin?.user?.id;
+ if (typeof adminId === "number") return { type: "staff", userId: adminId };
+
+ const user = c.get("user") as null | { id?: unknown };
+ if (typeof user?.id === "number") return { type: "api", userId: user.id };
+
+ return { type: "system", userId: null };
+};
+
+/** The actor a background task runs as. Spelled out so no call site invents one. */
+export const CONTENT_SYSTEM_ACTOR: ContentActor = {
+ type: "system",
+ userId: null,
+};
diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts
index cb951e9d8..8f4465ea2 100644
--- a/packages/vitnode/src/content/server/column-builders.ts
+++ b/packages/vitnode/src/content/server/column-builders.ts
@@ -61,6 +61,24 @@ export const buildPublicationColumns = (): Record<
.default("draft"),
});
+/**
+ * The one column `editorial: { enabled: true }` adds.
+ *
+ * `DEFAULT 1 NOT NULL`, so drizzle-kit backfills an existing table in a single
+ * statement and every pre-existing row starts at version 1 - the same property
+ * that makes adding `status DEFAULT 'draft'` safe.
+ *
+ * Never written by `create` or `update`: the editorial service increments it in
+ * the same conditional `UPDATE` that guards on it, which is what makes the
+ * check-and-set atomic.
+ */
+export const buildEditorialColumns = (): Record<
+ string,
+ PgColumnBuilderBase
+> => ({
+ version: integer().notNull().default(1),
+});
+
/**
* Applies `NOT NULL` and the column default.
*
diff --git a/packages/vitnode/src/content/server/editorial-effects.test.ts b/packages/vitnode/src/content/server/editorial-effects.test.ts
new file mode 100644
index 000000000..f1c22dd3a
--- /dev/null
+++ b/packages/vitnode/src/content/server/editorial-effects.test.ts
@@ -0,0 +1,203 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { testEditorialPostContentType } from "@/tests/content-fixtures";
+
+import type { ContentEditorialOutcome } from "./editorial-service";
+
+const syncContentSearch = vi.fn();
+
+vi.mock("./search-sync", () => ({
+ syncContentSearch: (...args: unknown[]) => syncContentSearch(...args),
+}));
+
+const { contentEditorialEffects } = await import("./editorial-effects");
+
+const OWNER = "@vitnode/example";
+
+const outcome = (
+ overrides: Partial> = {},
+): ContentEditorialOutcome =>
+ ({
+ changed: true,
+ changedFields: [],
+ operation: "publish",
+ previousSlug: null,
+ restoredFromRevisionId: null,
+ revisionId: 90,
+ row: {
+ id: 7,
+ publishedAt: new Date("2026-08-05T12:00:00.000Z"),
+ slug: "hello-world",
+ status: "published",
+ title: "Hello world",
+ version: 4,
+ },
+ version: 4,
+ ...overrides,
+ }) as unknown as ContentEditorialOutcome;
+
+const harness = ({
+ contextPlugin = "@vitnode/core",
+ emit = vi.fn().mockResolvedValue({
+ delivered: 1,
+ eventId: "event-1",
+ failures: [],
+ status: "delivered",
+ }),
+}: { contextPlugin?: string; emit?: ReturnType } = {}) => {
+ const store: Record = {
+ events: { emit },
+ plugin: { id: contextPlugin },
+ };
+
+ return {
+ c: { get: (key: string) => store[key] } as unknown as Context,
+ emit,
+ };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ syncContentSearch.mockResolvedValue({
+ action: "upsert",
+ documentId: "example.post:7",
+ });
+});
+
+describe("contentEditorialEffects", () => {
+ it("returns what the event transport and the index both reported", async () => {
+ // Both, because `EventsModel.emit` does not throw: discarding its result
+ // makes a dead listener look exactly like a delivered one.
+ const { c } = harness();
+
+ const result = await contentEditorialEffects(
+ c,
+ testEditorialPostContentType,
+ outcome(),
+ { pluginId: OWNER },
+ );
+
+ expect(result.event).toMatchObject({ delivered: 1, failures: [] });
+ expect(result.search).toMatchObject({ action: "upsert" });
+ });
+
+ it("surfaces a listener failure rather than swallowing it", async () => {
+ const { c } = harness({
+ emit: vi.fn().mockResolvedValue({
+ delivered: 0,
+ eventId: "event-1",
+ failures: [
+ {
+ error: "Service unavailable",
+ listener: "send-notification",
+ module: "notifications",
+ pluginId: OWNER,
+ },
+ ],
+ status: "delivered",
+ }),
+ });
+
+ const result = await contentEditorialEffects(
+ c,
+ testEditorialPostContentType,
+ outcome(),
+ { pluginId: OWNER },
+ );
+
+ expect(result.event?.failures).toHaveLength(1);
+ });
+
+ it("still writes the search document when the event failed", async () => {
+ // Two independent systems, and an interactive mutation has already
+ // committed by the time either runs.
+ const { c } = harness({
+ emit: vi.fn().mockResolvedValue({
+ delivered: 0,
+ eventId: "event-1",
+ failures: [
+ {
+ error: "down",
+ listener: "l",
+ module: "m",
+ pluginId: OWNER,
+ },
+ ],
+ status: "delivered",
+ }),
+ });
+
+ await contentEditorialEffects(c, testEditorialPostContentType, outcome(), {
+ pluginId: OWNER,
+ });
+
+ expect(syncContentSearch).toHaveBeenCalledTimes(1);
+ });
+
+ it("credits the content type's owner, not the plugin on the context", async () => {
+ const { c, emit } = harness({ contextPlugin: "@vitnode/core" });
+
+ await contentEditorialEffects(c, testEditorialPostContentType, outcome(), {
+ pluginId: OWNER,
+ });
+
+ expect(emit.mock.calls[0][2]).toEqual({ pluginId: OWNER });
+ });
+
+ it("does no work at all for a no-op outcome", async () => {
+ // A double-clicked publish button transitions nothing, so there is nothing
+ // to announce and nothing to index.
+ const { c, emit } = harness();
+
+ const result = await contentEditorialEffects(
+ c,
+ testEditorialPostContentType,
+ outcome({ changed: false }),
+ { pluginId: OWNER },
+ );
+
+ expect(result).toEqual({ event: null, search: null });
+ expect(emit).not.toHaveBeenCalled();
+ expect(syncContentSearch).not.toHaveBeenCalled();
+ });
+
+ describe("the payload", () => {
+ it("carries no scheduling keys for an interactive mutation", async () => {
+ // Absent rather than null, so no existing listener sees a new field.
+ const { c, emit } = harness();
+
+ await contentEditorialEffects(
+ c,
+ testEditorialPostContentType,
+ outcome(),
+ { pluginId: OWNER },
+ );
+
+ const payload = emit.mock.calls[0][1] as Record;
+ expect(payload).not.toHaveProperty("scheduleId");
+ expect(payload).not.toHaveProperty("scheduledBy");
+ });
+
+ it("carries the booking and its owner when a schedule fired it", async () => {
+ const { c, emit } = harness();
+
+ await contentEditorialEffects(
+ c,
+ testEditorialPostContentType,
+ outcome(),
+ { pluginId: OWNER, scheduledBy: 3, scheduleId: 55 },
+ );
+
+ expect(emit.mock.calls[0][1]).toMatchObject({
+ contentId: 7,
+ revisionId: 90,
+ scheduledBy: 3,
+ scheduleId: 55,
+ version: 4,
+ });
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts
new file mode 100644
index 000000000..567ab8933
--- /dev/null
+++ b/packages/vitnode/src/content/server/editorial-effects.ts
@@ -0,0 +1,153 @@
+import type { Context } from "hono";
+
+import type { EventEmitResult } from "../../api/models/events";
+import type { ContentEventAction } from "../events";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentEditorialOutcome } from "./editorial-service";
+import type { ContentSearchSyncOutcome } from "./search-sync";
+
+import { emitContentEvent } from "./emit";
+import { syncContentSearch } from "./search-sync";
+
+/** A `delete` has no event action of its own beyond the existing one. */
+const EVENT_ACTION: Record<
+ ContentEditorialOutcome["operation"],
+ ContentEventAction
+> = {
+ create: "created",
+ delete: "deleted",
+ publish: "published",
+ restore: "restored",
+ unpublish: "unpublished",
+ update: "updated",
+};
+
+const payloadFor = (
+ outcome: ContentEditorialOutcome,
+ {
+ scheduledBy,
+ scheduleId,
+ }: Pick,
+): Record => {
+ const base = {
+ contentId: outcome.row.id,
+ revisionId: outcome.revisionId ?? undefined,
+ // Both present only when a schedule caused this. A listener that wants to
+ // know "was this a person, right now?" reads the envelope's actor; these
+ // answer the different questions of who set it up, possibly weeks ago, and
+ // which booking this is - the idempotency key for a listener that must act
+ // once across the effects task's retries.
+ ...(scheduledBy === undefined ? {} : { scheduledBy }),
+ ...(scheduleId === undefined ? {} : { scheduleId }),
+ version: outcome.version,
+ };
+
+ switch (outcome.operation) {
+ case "publish": {
+ const publishedAt = (outcome.row as { publishedAt?: unknown })
+ .publishedAt;
+
+ return publishedAt instanceof Date
+ ? { ...base, publishedAt }
+ : { ...base };
+ }
+ case "restore":
+ return {
+ ...base,
+ changedFields: outcome.changedFields,
+ restoredFromRevisionId: outcome.restoredFromRevisionId,
+ };
+ case "update":
+ return { ...base, changedFields: outcome.changedFields };
+ default:
+ return base;
+ }
+};
+
+export interface ContentEditorialEffectsOptions {
+ /** The plugin that owns the content type, and therefore the event. */
+ pluginId: string;
+ /**
+ * The person who created the schedule that caused this, when one did.
+ *
+ * `undefined` for an interactive mutation, so the payload is unchanged
+ * there - the key is absent rather than null, and nothing existing sees a
+ * new field.
+ */
+ scheduledBy?: null | number;
+ /**
+ * The booking that caused this, when one did. Also `undefined` interactively.
+ *
+ * This is the identifier a listener uses to make itself idempotent: delivery
+ * is at-least-once, so the same `published` can arrive twice, but never with
+ * two different `scheduleId`s for the same booking.
+ */
+ scheduleId?: number;
+}
+
+export interface ContentEditorialEffectsResult {
+ /**
+ * What the event transport reported. `null` for a no-op outcome, which emits
+ * nothing at all.
+ *
+ * Present rather than discarded because `EventsModel.emit` does not throw:
+ * `failures` is the only place a dead listener or a broker outage is visible,
+ * and a caller that ignores it has decided - explicitly or not - that the
+ * event is allowed to go missing.
+ */
+ event: EventEmitResult | null;
+ search: ContentSearchSyncOutcome | null;
+}
+
+/**
+ * Everything one editorial mutation owes the rest of the system, once its
+ * transaction has committed.
+ *
+ * One function rather than the same four-line block in every route and in the
+ * queue handler: "which event, and which search operation" is a rule, and a
+ * rule copied into three places is a rule that will disagree with itself. The
+ * generated routes call it, and so does the scheduled-publication task.
+ *
+ * **Call it only after the write has returned - never inside the transaction.**
+ * Same rule `syncContentSearch` states for itself, and for the same reason: a
+ * rollback cannot un-emit an event or un-index a document.
+ *
+ * A no-op outcome does nothing at all. That is what keeps a double-clicked
+ * publish button, a retried queue task and an empty edit from each producing a
+ * second event and a second index write.
+ *
+ * Cache invalidation is deliberately **not** here. It needs the Next runtime,
+ * which neither the API process nor the queue worker has; the Server Action
+ * owns it, and the scheduled path goes through the revalidation bridge.
+ */
+export const contentEditorialEffects = async (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+ outcome: ContentEditorialOutcome,
+ { pluginId, scheduledBy, scheduleId }: ContentEditorialEffectsOptions,
+): Promise => {
+ if (!outcome.changed) return { event: null, search: null };
+
+ const event = await emitContentEvent(
+ c,
+ definition,
+ EVENT_ACTION[outcome.operation],
+ payloadFor(outcome, { scheduledBy, scheduleId }) as never,
+ // The plugin that owns the content type, not whichever module happens to be
+ // handling the request. Passed on every path, interactive and scheduled, so
+ // the envelope's owner is a property of the event rather than of how it was
+ // triggered.
+ { pluginId },
+ );
+
+ return {
+ event,
+ search: await syncContentSearch(c, definition, {
+ changed: outcome.changed,
+ changedFields: outcome.changedFields,
+ operation: outcome.operation,
+ pluginId,
+ row: outcome.row,
+ }),
+ };
+};
diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts
new file mode 100644
index 000000000..78a66b761
--- /dev/null
+++ b/packages/vitnode/src/content/server/editorial-service.test.ts
@@ -0,0 +1,659 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ testCategoryContentType,
+ testEditorialNoteContentType,
+ testEditorialPostContentType,
+} from "@/tests/content-fixtures";
+
+import type { ContentRevisionSnapshot } from "../revisions";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentEditorialService } from "./editorial-service";
+import type { ContentModel } from "./model";
+
+import {
+ ContentRevisionNotRestorable,
+ ContentVersionConflict,
+} from "../errors";
+import { createContentModel } from "./model";
+
+const categories = createContentModel(testCategoryContentType);
+const posts = createContentModel(testEditorialPostContentType);
+const notes = createContentModel(testEditorialNoteContentType);
+
+const STAFF = { type: "staff", userId: 7 } as const;
+const SYSTEM = { type: "system", userId: null } as const;
+
+interface RecordedCall {
+ arg: unknown;
+ op: string;
+}
+
+/**
+ * The chainable Drizzle stand-in from `service.test.ts`, plus `transaction`.
+ *
+ * The transaction callback receives the same handle, so a test can assert that
+ * the content write and the revision insert landed in the same unit of work by
+ * counting them - and `failAt` makes the revision insert throw so the rollback
+ * path is exercised rather than assumed.
+ */
+const createDbMock = (
+ results: unknown[][],
+ { failAt }: { failAt?: number } = {},
+) => {
+ const calls: RecordedCall[] = [];
+ const queue = [...results];
+ let started = 0;
+ let rolledBack = false;
+
+ const chain = (rows: unknown[]) => {
+ const record = (op: string, arg: unknown) => {
+ calls.push({ arg, op });
+
+ return builder;
+ };
+
+ const builder = {
+ $dynamic: () => builder,
+ from: (value: unknown) => record("from", value),
+ leftJoin: (value: unknown) => record("leftJoin", value),
+ limit: (value: unknown) => record("limit", value),
+ orderBy: (value: unknown) => record("orderBy", value),
+ returning: (value: unknown) => record("returning", value),
+ set: (value: unknown) => record("set", value),
+ then: async (resolve: (rows: unknown[]) => TResult) =>
+ Promise.resolve(rows).then(resolve),
+ values: (value: unknown) => record("values", value),
+ where: (value: unknown) => record("where", value),
+ };
+
+ return builder;
+ };
+
+ const start = (op: string) => (arg: unknown) => {
+ started += 1;
+ calls.push({ arg, op });
+
+ if (failAt !== undefined && started === failAt) {
+ throw new Error("insert failed");
+ }
+
+ return chain(queue.shift() ?? []);
+ };
+
+ const db = {
+ delete: start("delete"),
+ insert: start("insert"),
+ select: start("select"),
+ transaction: async (
+ body: (tx: unknown) => Promise,
+ ): Promise => {
+ try {
+ return await body(db);
+ } catch (error) {
+ rolledBack = true;
+ throw error;
+ }
+ },
+ update: start("update"),
+ };
+
+ const c = {
+ get: (key: string) => (key === "db" ? db : undefined),
+ } as Context;
+
+ return { c, calls, didRollBack: () => rolledBack };
+};
+
+const opsOf = (calls: RecordedCall[], op: string) =>
+ calls.filter(call => call.op === op).map(call => call.arg);
+
+/**
+ * Which columns a Drizzle condition actually names.
+ *
+ * `JSON.stringify` cannot be used - a `PgColumn` holds a reference back to its
+ * table - so the nested `queryChunks` are walked instead, collecting anything
+ * that carries a column `name`.
+ */
+const columnsIn = (condition: unknown): string[] => {
+ const walk = (value: unknown): unknown[] =>
+ value !== null && typeof value === "object" && "queryChunks" in value
+ ? (value.queryChunks as unknown[]).flatMap(walk)
+ : [value];
+
+ return walk(condition)
+ .map(chunk => (chunk as null | { name?: unknown })?.name)
+ .filter((name): name is string => typeof name === "string");
+};
+
+/**
+ * `editorialService` is `undefined` for a content type without the workflow, so
+ * every call site would otherwise need a non-null assertion. Throwing here
+ * keeps the tests readable and fails loudly if a fixture ever loses its
+ * `editorial` block.
+ */
+const editorialServiceOf = (
+ model: ContentModel,
+ c: Context,
+): ContentEditorialService => {
+ const build = model.editorialService;
+ if (!build)
+ throw new Error(`${model.definition.id} has no editorial service`);
+
+ return build(c, { pluginId: "@vitnode/test" });
+};
+
+const service = (c: Context) => editorialServiceOf(posts, c);
+
+const noteService = (c: Context) => editorialServiceOf(notes, c);
+
+const row = (overrides: Record = {}) => ({
+ createdAt: new Date("2024-01-01T00:00:00.000Z"),
+ excerpt: null,
+ id: 1,
+ publishedAt: null,
+ slug: "hello",
+ status: "draft",
+ title: "Hello",
+ updatedAt: new Date("2024-01-02T00:00:00.000Z"),
+ version: 1,
+ views: 0,
+ ...overrides,
+});
+
+const snapshot = (
+ fields: Record,
+ version = 1,
+): ContentRevisionSnapshot =>
+ ({
+ contentTypeId: "test.editorial",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ fields,
+ id: 1,
+ publication: { publishedAt: null, status: "draft" },
+ schemaVersion: 1,
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ version,
+ }) as ContentRevisionSnapshot;
+
+describe("editorial service", () => {
+ it("is undefined for a content type without the workflow", () => {
+ expect(categories.editorialService).toBeUndefined();
+ expect(posts.editorialService).toBeDefined();
+ });
+
+ describe("create", () => {
+ it("starts at version 1 and captures a create revision", async () => {
+ const { c, calls } = createDbMock([[row({ version: 1 })], [{ id: 10 }]]);
+
+ const result = await service(c).create(
+ { title: "Hello" },
+ { actor: STAFF },
+ );
+
+ expect(result.changed).toBe(true);
+ expect(result.version).toBe(1);
+ expect(result.revisionId).toBe(10);
+ expect(result.operation).toBe("create");
+
+ const revision = opsOf(calls, "values")[1] as Record;
+ expect(revision.version).toBe(1);
+ expect(revision.operation).toBe("create");
+ expect(revision.actorType).toBe("staff");
+ expect(revision.actorUserId).toBe(7);
+ expect(revision.contentTypeId).toBe("test.editorial");
+ expect(revision.pluginId).toBe("@vitnode/test");
+ });
+
+ it("records a system actor without inventing a user id", async () => {
+ const { c, calls } = createDbMock([[row()], [{ id: 10 }]]);
+
+ await service(c).create({ title: "Hello" }, { actor: SYSTEM });
+
+ const revision = opsOf(calls, "values")[1] as Record;
+ expect(revision.actorType).toBe("system");
+ expect(revision.actorUserId).toBeNull();
+ });
+ });
+
+ describe("update", () => {
+ it("increments the version and captures one revision", async () => {
+ const { c, calls } = createDbMock([
+ [row({ version: 4 })],
+ [row({ title: "Changed", version: 5 })],
+ [{ id: 11 }],
+ ]);
+
+ const result = await service(c).update(
+ 1,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 4 },
+ );
+
+ expect(result?.changed).toBe(true);
+ expect(result?.version).toBe(5);
+ expect(result?.changedFields).toEqual(["title"]);
+ // Exactly one revision insert, and exactly one content update.
+ expect(opsOf(calls, "update")).toHaveLength(1);
+ expect(opsOf(calls, "insert")).toHaveLength(1);
+ });
+
+ it("guards the write on the expected version", async () => {
+ const { c, calls } = createDbMock([
+ [row({ version: 4 })],
+ [row({ title: "Changed", version: 5 })],
+ [{ id: 11 }],
+ ]);
+
+ await service(c).update(
+ 1,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 4 },
+ );
+
+ // `version = version + 1` travels with the same statement that checks it,
+ // which is what makes check-and-set atomic.
+ const set = opsOf(calls, "set")[0] as Record;
+ expect(set.version).toBeDefined();
+ expect(set.title).toBe("Changed");
+ });
+
+ it("reports a conflict when the version moved", async () => {
+ const { c } = createDbMock([
+ [row({ version: 4 })],
+ // The guarded UPDATE matches nothing...
+ [],
+ // ...and the record is still there, at a newer version.
+ [{ version: 9 }],
+ ]);
+
+ await expect(
+ service(c).update(
+ 1,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 4 },
+ ),
+ ).rejects.toThrow(ContentVersionConflict);
+ });
+
+ it("carries both versions on the conflict", async () => {
+ const { c } = createDbMock([[row({ version: 4 })], [], [{ version: 9 }]]);
+
+ const error = await service(c)
+ .update(1, { title: "Changed" }, { actor: STAFF, expectedVersion: 4 })
+ .catch((thrown: unknown) => thrown);
+
+ expect(error).toBeInstanceOf(ContentVersionConflict);
+ const conflict = error as ContentVersionConflict;
+ expect(conflict.expectedVersion).toBe(4);
+ expect(conflict.currentVersion).toBe(9);
+ expect(conflict.itemId).toBe(1);
+ });
+
+ it("returns null for a record that does not exist", async () => {
+ const { c } = createDbMock([[]]);
+
+ await expect(
+ service(c).update(
+ 99,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 1 },
+ ),
+ ).resolves.toBeNull();
+ });
+
+ it("writes nothing at all when the diff is empty", async () => {
+ const { c, calls } = createDbMock([
+ [row({ title: "Hello", version: 4 })],
+ ]);
+
+ const result = await service(c).update(
+ 1,
+ { title: "Hello" },
+ { actor: STAFF, expectedVersion: 4 },
+ );
+
+ expect(result?.changed).toBe(false);
+ expect(result?.version).toBe(4);
+ expect(result?.revisionId).toBeNull();
+ expect(opsOf(calls, "update")).toHaveLength(0);
+ expect(opsOf(calls, "insert")).toHaveLength(0);
+ });
+
+ it("rolls back the content write when the revision insert fails", async () => {
+ // 1 select, 2 update, 3 insert <- fails
+ const { c, didRollBack } = createDbMock(
+ [[row({ version: 4 })], [row({ title: "Changed", version: 5 })]],
+ { failAt: 3 },
+ );
+
+ await expect(
+ service(c).update(
+ 1,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 4 },
+ ),
+ ).rejects.toThrow("insert failed");
+
+ expect(didRollBack()).toBe(true);
+ });
+ });
+
+ describe("publish and unpublish", () => {
+ it("increments the version on a real transition", async () => {
+ const { c, calls } = createDbMock([
+ [row({ publishedAt: new Date(), status: "published", version: 3 })],
+ [{ id: 12 }],
+ ]);
+
+ const result = await service(c).publish(1, { actor: STAFF });
+
+ expect(result?.changed).toBe(true);
+ expect(result?.version).toBe(3);
+ expect(
+ (opsOf(calls, "values")[0] as Record).operation,
+ ).toBe("publish");
+ });
+
+ it("leaves the version alone and writes no revision when idempotent", async () => {
+ const { c, calls } = createDbMock([
+ // The guarded UPDATE matches nothing - already published.
+ [],
+ [row({ status: "published", version: 3 })],
+ ]);
+
+ const result = await service(c).publish(1, { actor: STAFF });
+
+ expect(result?.changed).toBe(false);
+ expect(result?.version).toBe(3);
+ expect(result?.revisionId).toBeNull();
+ expect(opsOf(calls, "insert")).toHaveLength(0);
+ });
+
+ it("returns null when the record is gone", async () => {
+ const { c } = createDbMock([[], []]);
+
+ await expect(service(c).publish(1, { actor: STAFF })).resolves.toBeNull();
+ });
+
+ it("enforces an expected version when one is supplied", async () => {
+ const { c } = createDbMock([[], [row({ status: "draft", version: 9 })]]);
+
+ await expect(
+ service(c).publish(1, { actor: STAFF, expectedVersion: 4 }),
+ ).rejects.toThrow(ContentVersionConflict);
+ });
+
+ it("unpublishes without touching publishedAt", async () => {
+ const { c, calls } = createDbMock([
+ [row({ publishedAt: new Date(), status: "draft", version: 4 })],
+ [{ id: 13 }],
+ ]);
+
+ await service(c).unpublish(1, { actor: STAFF });
+
+ const set = opsOf(calls, "set")[0] as Record;
+ expect(set.status).toBe("draft");
+ expect(set).not.toHaveProperty("publishedAt");
+ });
+ });
+
+ describe("delete", () => {
+ it("captures a final revision one version past the last", async () => {
+ const { c, calls } = createDbMock([[row({ version: 6 })], [{ id: 14 }]]);
+
+ const result = await service(c).delete(1, {
+ actor: STAFF,
+ expectedVersion: 6,
+ });
+
+ expect(result?.operation).toBe("delete");
+ // The row is gone, so nothing holds version 7 - but the history stays
+ // strictly increasing and the unique index stays meaningful.
+ expect(result?.version).toBe(7);
+ expect(
+ (opsOf(calls, "values")[0] as Record).version,
+ ).toBe(7);
+ });
+
+ it("guards the DELETE on the version it was given", async () => {
+ // The precondition has to be part of the statement that removes the row.
+ // Reading the version first and deleting second is the very race this
+ // exists to close.
+ const { c, calls } = createDbMock([[row({ version: 6 })], [{ id: 14 }]]);
+
+ await service(c).delete(1, { actor: STAFF, expectedVersion: 6 });
+
+ expect(columnsIn(opsOf(calls, "where")[0])).toEqual(
+ expect.arrayContaining(["id", "version"]),
+ );
+ });
+
+ it("returns null when there was nothing to delete", async () => {
+ // Nothing deleted and nothing there: the caller wanted it gone, and it
+ // is. A 404, never a conflict.
+ const { c } = createDbMock([[], []]);
+
+ await expect(
+ service(c).delete(1, { actor: STAFF, expectedVersion: 6 }),
+ ).resolves.toBeNull();
+ });
+
+ it("refuses to delete a version the caller has not seen", async () => {
+ // Nothing deleted, but the record is still there at a newer version -
+ // somebody saved after this table was rendered.
+ const { c } = createDbMock([[], [{ version: 9 }]]);
+
+ await expect(
+ service(c).delete(1, { actor: STAFF, expectedVersion: 6 }),
+ ).rejects.toMatchObject({
+ currentVersion: 9,
+ expectedVersion: 6,
+ name: "ContentVersionConflict",
+ });
+ });
+
+ it("writes no revision when the delete is refused", async () => {
+ const { c, calls } = createDbMock([[], [{ version: 9 }]]);
+
+ await expect(
+ service(c).delete(1, { actor: STAFF, expectedVersion: 6 }),
+ ).rejects.toThrow();
+
+ expect(opsOf(calls, "values")).toHaveLength(0);
+ });
+ });
+
+ describe("retention", () => {
+ it("prunes past the window in the same transaction", async () => {
+ // Retention is 10 on this fixture, so a write at version 12 drops
+ // everything at or below version 2.
+ const { c, calls } = createDbMock([
+ [row({ version: 11 })],
+ [row({ title: "Changed", version: 12 })],
+ [{ id: 15 }],
+ [],
+ ]);
+
+ await service(c).update(
+ 1,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 11 },
+ );
+
+ expect(opsOf(calls, "delete")).toHaveLength(1);
+ });
+
+ it("does not prune while the history is inside the window", async () => {
+ const { c, calls } = createDbMock([
+ [row({ version: 2 })],
+ [row({ title: "Changed", version: 3 })],
+ [{ id: 15 }],
+ ]);
+
+ await service(c).update(
+ 1,
+ { title: "Changed" },
+ { actor: STAFF, expectedVersion: 2 },
+ );
+
+ expect(opsOf(calls, "delete")).toHaveLength(0);
+ });
+ });
+
+ describe("restore", () => {
+ const restoreMock = (
+ current: Record,
+ revisionSnapshot: ContentRevisionSnapshot,
+ rest: unknown[][] = [],
+ ) =>
+ createDbMock([
+ // findById
+ [{ id: 3, snapshot: revisionSnapshot, version: 2 }],
+ // readOne
+ [current],
+ ...rest,
+ ]);
+
+ it("applies the snapshot's fields and creates a new version", async () => {
+ const { c, calls } = restoreMock(
+ row({ title: "Now", version: 8 }),
+ snapshot({ excerpt: null, slug: "hello", title: "Then", views: 0 }),
+ [[row({ title: "Then", version: 9 })], [{ id: 16 }]],
+ );
+
+ const result = await service(c).restore(1, 3, {
+ actor: STAFF,
+ expectedVersion: 8,
+ });
+
+ expect(result?.changed).toBe(true);
+ expect(result?.version).toBe(9);
+ expect(result?.changedFields).toEqual(["title"]);
+
+ const revision = opsOf(calls, "values")[0] as Record;
+ expect(revision.operation).toBe("restore");
+ expect(revision.restoredFromRevisionId).toBe(3);
+ // The restored revision's own version is never reinstated.
+ expect(revision.version).toBe(9);
+ });
+
+ it("never writes the publication columns", async () => {
+ const { c, calls } = restoreMock(
+ row({ status: "published", title: "Now", version: 8 }),
+ snapshot({ excerpt: null, slug: "hello", title: "Then", views: 0 }),
+ [[row({ title: "Then", version: 9 })], [{ id: 16 }]],
+ );
+
+ await service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 });
+
+ const set = opsOf(calls, "set")[0] as Record;
+ expect(set).not.toHaveProperty("status");
+ expect(set).not.toHaveProperty("publishedAt");
+ });
+
+ it("ignores a field the content type no longer declares", async () => {
+ const { c, calls } = restoreMock(
+ row({ title: "Now", version: 8 }),
+ snapshot({
+ excerpt: null,
+ removedField: "gone",
+ slug: "hello",
+ title: "Then",
+ views: 0,
+ }),
+ [[row({ title: "Then", version: 9 })], [{ id: 16 }]],
+ );
+
+ await service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 });
+
+ const set = opsOf(calls, "set")[0] as Record;
+ expect(set).not.toHaveProperty("removedField");
+ });
+
+ it("refuses a snapshot that is invalid under the current rules", async () => {
+ const { c } = restoreMock(
+ row({ title: "Now", version: 8 }),
+ // `title` has minLength 3 on this fixture.
+ snapshot({ excerpt: null, slug: "hello", title: "no", views: 0 }),
+ );
+
+ await expect(
+ service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }),
+ ).rejects.toThrow(ContentRevisionNotRestorable);
+ });
+
+ it("names only field names when it refuses", async () => {
+ const { c } = restoreMock(
+ row({ title: "Now", version: 8 }),
+ snapshot({ excerpt: null, slug: "hello", title: "no", views: 0 }),
+ );
+
+ const error = await service(c)
+ .restore(1, 3, { actor: STAFF, expectedVersion: 8 })
+ .catch((thrown: unknown) => thrown);
+
+ expect((error as ContentRevisionNotRestorable).fields).toEqual(["title"]);
+ });
+
+ it("writes nothing when the snapshot matches the record", async () => {
+ const { c, calls } = restoreMock(
+ row({ title: "Same", version: 8 }),
+ snapshot({ excerpt: null, slug: "hello", title: "Same", views: 0 }),
+ );
+
+ const result = await service(c).restore(1, 3, {
+ actor: STAFF,
+ expectedVersion: 8,
+ });
+
+ expect(result?.changed).toBe(false);
+ expect(result?.revisionId).toBeNull();
+ expect(opsOf(calls, "update")).toHaveLength(0);
+ });
+
+ it("reports a conflict when the version moved", async () => {
+ const { c } = restoreMock(
+ row({ title: "Now", version: 8 }),
+ snapshot({ excerpt: null, slug: "hello", title: "Then", views: 0 }),
+ [[], [{ version: 12 }]],
+ );
+
+ await expect(
+ service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }),
+ ).rejects.toThrow(ContentVersionConflict);
+ });
+
+ it("returns null for a revision that is not this record's", async () => {
+ const { c } = createDbMock([[]]);
+
+ await expect(
+ service(c).restore(1, 3, { actor: STAFF, expectedVersion: 8 }),
+ ).resolves.toBeNull();
+ });
+ });
+
+ describe("without publication", () => {
+ it("still versions and captures revisions", async () => {
+ const { c, calls } = createDbMock([
+ [{ body: null, id: 1, title: "Note", version: 1 }],
+ [{ id: 20 }],
+ ]);
+
+ const result = await noteService(c).create(
+ { title: "Note" },
+ { actor: STAFF },
+ );
+
+ expect(result.version).toBe(1);
+ const revision = opsOf(calls, "values")[1] as Record;
+ // No publication block on the snapshot - there is no lifecycle to record.
+ expect(
+ (revision.snapshot as ContentRevisionSnapshot).publication,
+ ).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts
new file mode 100644
index 000000000..d802826df
--- /dev/null
+++ b/packages/vitnode/src/content/server/editorial-service.ts
@@ -0,0 +1,637 @@
+import type { SQL } from "drizzle-orm";
+import type {
+ PgColumn,
+ PgTableWithColumns,
+ TableConfig,
+} from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { and, eq, ne, sql } from "drizzle-orm";
+
+import type { ContentActor, ContentRevisionOperation } from "../revisions";
+import type { ContentSchemas } from "../schemas";
+import type {
+ AnyContentTypeDefinition,
+ ContentCreateInput,
+ ContentFieldName,
+ ContentSelect,
+ ContentUpdateInput,
+} from "../types";
+import type { ContentRevisionsModel } from "./revisions-model";
+import type { ContentSchedulesModel } from "./schedules-model";
+import type { ContentDatabase } from "./service";
+
+import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const";
+import {
+ ContentEngineError,
+ ContentRevisionNotRestorable,
+ ContentVersionConflict,
+} from "../errors";
+import { diffChangedFields, toColumnValues } from "./query";
+import {
+ contentRevisionSnapshot,
+ projectRevisionSnapshot,
+} from "./revision-snapshot";
+import { createContentRevisionsModel } from "./revisions-model";
+import { createContentSchedulesModel } from "./schedules-model";
+import { createSlugNormalizer } from "./slugs";
+
+/**
+ * Everything the post-commit effects need, and nothing they have to re-read.
+ *
+ * `previousSlug` is the one field that cannot be recovered after the fact: once
+ * the write returns, the old URL is gone, and invalidating the wrong cache tag
+ * leaves a moved page resolving at its old address.
+ */
+export interface ContentEditorialOutcome {
+ /** `false` when nothing moved: no write, no revision, no event, no tags. */
+ changed: boolean;
+ changedFields: ContentFieldName[];
+ operation: ContentRevisionOperation;
+ /** The slug the record answered to *before* this mutation, if it has one. */
+ previousSlug: null | string;
+ /** Set only by `restore`: the revision the values came from. */
+ restoredFromRevisionId: null | number;
+ /** `null` on a no-op, since no revision was written. */
+ revisionId: null | number;
+ row: ContentSelect;
+ version: number;
+}
+
+export interface ContentEditorialOptions {
+ actor: ContentActor;
+ /** Join an existing transaction instead of opening one. */
+ tx?: ContentDatabase;
+}
+
+export interface ContentEditorialWriteOptions extends ContentEditorialOptions {
+ expectedVersion: number;
+}
+
+export interface ContentEditorialPublicationOptions extends ContentEditorialOptions {
+ /** Enforced when supplied. Publishing overwrites no field values, so it is
+ * optional: requiring it would fail the publish button whenever a colleague
+ * had fixed a typo, for no protection against a lost update. */
+ expectedVersion?: number;
+}
+
+export interface ContentEditorialService {
+ create: (
+ values: ContentCreateInput,
+ options: ContentEditorialOptions,
+ ) => Promise>;
+ /**
+ * Removes a record, and refuses if it moved since the caller read it.
+ *
+ * `expectedVersion` is required for the same reason `update` requires it: a
+ * delete is the widest possible overwrite. Somebody looking at v4 in a stale
+ * table must not be able to remove the v5 a colleague just wrote, and "are
+ * you sure?" cannot ask about a change the person has not seen.
+ */
+ delete: (
+ id: number,
+ options: ContentEditorialWriteOptions,
+ ) => Promise | null>;
+ publish: (
+ id: number,
+ options: ContentEditorialPublicationOptions,
+ ) => Promise | null>;
+ restore: (
+ id: number,
+ revisionId: number,
+ options: ContentEditorialWriteOptions,
+ ) => Promise | null>;
+ /** Revision reads. Writes go through the mutations above. */
+ revisions: ContentRevisionsModel;
+ /**
+ * Scheduled transitions, or `undefined` without `editorial.scheduling`.
+ *
+ * `undefined` rather than a throwing stub, matching `publicService` and
+ * `editorialService` themselves - the check reads naturally in code that does
+ * not know which content type it was handed.
+ */
+ schedules: ContentSchedulesModel | undefined;
+ unpublish: (
+ id: number,
+ options: ContentEditorialPublicationOptions,
+ ) => Promise | null>;
+ update: (
+ id: number,
+ values: ContentUpdateInput,
+ options: ContentEditorialWriteOptions,
+ ) => Promise | null>;
+}
+
+/**
+ * The transactional half of the Content Engine.
+ *
+ * Everything here holds one rule: **the content write, the version increment
+ * and the revision insert are one transaction, and nothing else is in it.** No
+ * event, no search call, no cache API, no HTTP - those all run after the commit,
+ * because a rolled-back transaction cannot un-send them.
+ *
+ * A caller that already owns a transaction passes `tx` and this joins it. A
+ * caller that does not gets one opened here, which is what makes
+ * `service.update(...)` atomic by default rather than only when someone
+ * remembered.
+ */
+export const createContentEditorialService = <
+ TDefinition extends AnyContentTypeDefinition,
+>({
+ c,
+ columns,
+ definition,
+ pluginId,
+ schemas,
+ table,
+}: {
+ c: Context;
+ columns: Record;
+ definition: TDefinition;
+ pluginId: string;
+ schemas: ContentSchemas;
+ table: PgTableWithColumns;
+}): ContentEditorialService => {
+ if (!definition.editorial.enabled) {
+ throw new ContentEngineError(
+ "The editorial service needs `editorial: { enabled: true }` on the content type.",
+ { contentTypeId: definition.id },
+ );
+ }
+
+ const contentTypeId = definition.id;
+ const fields = definition.fields;
+ const fieldNames = Object.keys(fields) as ContentFieldName[];
+ const primaryCursor = columns.id;
+ const versionColumn = columns.version;
+ const publication = definition.publication.enabled;
+ const slugField = definition.publicApi.enabled
+ ? definition.publicApi.slugField
+ : null;
+
+ const ownColumnNames = [
+ "id",
+ "createdAt",
+ "updatedAt",
+ ...(publication ? CONTENT_PUBLICATION_FIELDS : []),
+ ...CONTENT_EDITORIAL_FIELDS,
+ ...fieldNames,
+ ];
+ const ownSelection = (): Record =>
+ Object.fromEntries(ownColumnNames.map(name => [name, columns[name]]));
+
+ const revisions = createContentRevisionsModel({ c, definition, pluginId });
+ const schedules = definition.editorial.scheduling.enabled
+ ? createContentSchedulesModel({ c, definition, pluginId })
+ : undefined;
+ const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer(
+ contentTypeId,
+ fields,
+ );
+
+ const toRow = (row: Record): ContentSelect =>
+ row as ContentSelect;
+
+ const versionOf = (row: Record): number =>
+ typeof row.version === "number" ? row.version : 1;
+
+ const slugOf = (row: null | Record): null | string => {
+ if (!row || slugField === null) return null;
+ const value = row[slugField];
+
+ return typeof value === "string" ? value : null;
+ };
+
+ const readOne = async (
+ id: number,
+ database: ContentDatabase,
+ ): Promise> => {
+ const [row] = await database
+ .select(ownSelection())
+ .from(table)
+ .where(eq(primaryCursor, id))
+ .limit(1);
+
+ return row ?? null;
+ };
+
+ /** Runs `body` in the caller's transaction, or in one opened for it. */
+ const transact = async (
+ options: ContentEditorialOptions,
+ body: (tx: ContentDatabase) => Promise,
+ ): Promise => {
+ if (options.tx) return await body(options.tx);
+
+ return await c.get("db").transaction(async tx => await body(tx));
+ };
+
+ const capture = async (
+ tx: ContentDatabase,
+ {
+ actor,
+ changedFields,
+ operation,
+ restoredFromRevisionId,
+ row,
+ version,
+ }: {
+ actor: ContentActor;
+ changedFields: readonly string[];
+ operation: ContentRevisionOperation;
+ restoredFromRevisionId?: number;
+ row: Record;
+ version: number;
+ },
+ ): Promise =>
+ await revisions.capture(tx, {
+ actor,
+ changedFields,
+ itemId: typeof row.id === "number" ? row.id : 0,
+ operation,
+ restoredFromRevisionId,
+ // Stamped with the version the record now holds, which for a delete is the
+ // one it would have had - see `remove` below.
+ snapshot: contentRevisionSnapshot(definition, { ...row, version }),
+ version,
+ });
+
+ /**
+ * The conditional write every editorial mutation goes through.
+ *
+ * `WHERE id = $id AND version = $expected` is the whole locking mechanism:
+ * two editors racing produce one `UPDATE` that matches and one that does not,
+ * with no read-then-write window in between. The follow-up `SELECT` runs only
+ * when nothing matched, to tell a deleted record (404) from a moved one (409)
+ * - the same shape `transition` in the plain service already uses.
+ */
+ const guardedWrite = async (
+ tx: ContentDatabase,
+ id: number,
+ expectedVersion: number,
+ values: Record,
+ ): Promise> => {
+ const [row] = await tx
+ .update(table)
+ .set({ ...values, version: sql`${versionColumn} + 1` })
+ .where(and(eq(primaryCursor, id), eq(versionColumn, expectedVersion)))
+ .returning(ownSelection());
+
+ if (row) return row;
+
+ const [current] = await tx
+ .select({ version: versionColumn })
+ .from(table)
+ .where(eq(primaryCursor, id))
+ .limit(1);
+
+ if (!current) return null;
+
+ throw new ContentVersionConflict({
+ contentTypeId,
+ currentVersion: versionOf(current),
+ expectedVersion,
+ itemId: id,
+ });
+ };
+
+ /**
+ * Publish and unpublish, which guard on the *state* rather than the version.
+ *
+ * The state guard is what makes them idempotent, and idempotency is what makes
+ * a retried queue task harmless. An `expectedVersion`, when supplied, is
+ * `AND`ed on top rather than replacing it.
+ */
+ const transition = async (
+ id: number,
+ options: ContentEditorialPublicationOptions,
+ operation: "publish" | "unpublish",
+ values: Record,
+ guard: SQL,
+ ): Promise | null> =>
+ await transact(options, async tx => {
+ const conditions = [eq(primaryCursor, id), guard];
+ if (options.expectedVersion !== undefined) {
+ conditions.push(eq(versionColumn, options.expectedVersion));
+ }
+
+ const [row] = await tx
+ .update(table)
+ .set({ ...values, version: sql`${versionColumn} + 1` })
+ .where(and(...conditions))
+ .returning(ownSelection());
+
+ if (!row) {
+ const current = await readOne(id, tx);
+ if (!current) return null;
+
+ // Nothing matched but the record exists: either it was already in the
+ // requested state, or the version moved. Only the second is an error.
+ if (
+ options.expectedVersion !== undefined &&
+ versionOf(current) !== options.expectedVersion
+ ) {
+ throw new ContentVersionConflict({
+ contentTypeId,
+ currentVersion: versionOf(current),
+ expectedVersion: options.expectedVersion,
+ itemId: id,
+ });
+ }
+
+ return {
+ changed: false,
+ changedFields: [],
+ operation,
+ previousSlug: slugOf(current),
+ restoredFromRevisionId: null,
+ revisionId: null,
+ row: toRow(current),
+ version: versionOf(current),
+ };
+ }
+
+ const version = versionOf(row);
+ const revisionId = await capture(tx, {
+ actor: options.actor,
+ changedFields: [],
+ operation,
+ row,
+ version,
+ });
+
+ return {
+ changed: true,
+ changedFields: [],
+ operation,
+ previousSlug: slugOf(row),
+ restoredFromRevisionId: null,
+ revisionId,
+ row: toRow(row),
+ version,
+ };
+ });
+
+ return {
+ create: async (values, options) =>
+ await transact(options, async tx => {
+ const parsed = schemas.create.parse(values) as Record;
+
+ const [row] = await tx
+ .insert(table)
+ .values(toColumnValues(fields, withCreateSlugs(parsed)))
+ .returning(ownSelection());
+
+ const version = versionOf(row);
+ const revisionId = await capture(tx, {
+ actor: options.actor,
+ // Everything is new, so every field "changed" - which is what the
+ // history should say about a create.
+ changedFields: fieldNames,
+ operation: "create",
+ row,
+ version,
+ });
+
+ return {
+ changed: true,
+ changedFields: fieldNames,
+ operation: "create",
+ previousSlug: null,
+ restoredFromRevisionId: null,
+ revisionId,
+ row: toRow(row),
+ version,
+ };
+ }),
+
+ delete: async (id, options) =>
+ await transact(options, async tx => {
+ // Same guard as `guardedWrite`, in a `DELETE` - the version has to be
+ // part of the statement that removes the row, not checked before it.
+ const [row] = await tx
+ .delete(table)
+ .where(
+ and(
+ eq(primaryCursor, id),
+ eq(versionColumn, options.expectedVersion),
+ ),
+ )
+ .returning(ownSelection());
+
+ if (!row) {
+ const [current] = await tx
+ .select({ version: versionColumn })
+ .from(table)
+ .where(eq(primaryCursor, id))
+ .limit(1);
+
+ // Gone already is a 404 and not a conflict: the caller wanted the
+ // record removed, and it is.
+ if (!current) return null;
+
+ throw new ContentVersionConflict({
+ contentTypeId,
+ currentVersion: versionOf(current),
+ expectedVersion: options.expectedVersion,
+ itemId: id,
+ });
+ }
+
+ // The row is gone, so no version survives to hold this one. Recording
+ // `version + 1` keeps the per-record history strictly increasing and
+ // keeps the unique index meaningful - the alternative collides with the
+ // revision that last wrote this version.
+ const version = versionOf(row) + 1;
+ const revisionId = await capture(tx, {
+ actor: options.actor,
+ changedFields: [],
+ operation: "delete",
+ row,
+ version,
+ });
+
+ return {
+ changed: true,
+ changedFields: [],
+ operation: "delete",
+ previousSlug: slugOf(row),
+ restoredFromRevisionId: null,
+ revisionId,
+ row: toRow(row),
+ version,
+ };
+ }),
+
+ publish: async (id, options) =>
+ await transition(
+ id,
+ options,
+ "publish",
+ {
+ // COALESCE, so a republish keeps the original date. `publishedAt` is
+ // the first-published timestamp and is never rewritten.
+ publishedAt: sql`coalesce(${columns.publishedAt}, now())`,
+ status: "published",
+ },
+ ne(columns.status, "published"),
+ ),
+
+ restore: async (id, revisionId, options) =>
+ await transact(options, async tx => {
+ const revision = await revisions.findById(id, revisionId, tx);
+ if (!revision) return null;
+
+ const current = await readOne(id, tx);
+ if (!current) return null;
+
+ // Currently declared fields only. A field the content type has since
+ // dropped is ignored; one added since is absent, so the record keeps
+ // what it has.
+ const projected = projectRevisionSnapshot(
+ definition,
+ revision.snapshot,
+ );
+
+ const parsed = schemas.update.safeParse(projected);
+ if (!parsed.success) {
+ throw new ContentRevisionNotRestorable({
+ contentTypeId,
+ // Field names only - never the issue tree, which names internal
+ // paths and is already described by the route's OpenAPI schema.
+ fields: [
+ ...new Set(
+ parsed.error.issues
+ .map(issue => String(issue.path[0] ?? ""))
+ .filter(name => name !== ""),
+ ),
+ ],
+ revisionId,
+ });
+ }
+
+ const patch = withUpdateSlugs(parsed.data);
+ const changedFields = diffChangedFields(fieldNames, current, patch);
+
+ if (changedFields.length === 0) {
+ return {
+ changed: false,
+ changedFields,
+ operation: "restore" as const,
+ previousSlug: slugOf(current),
+ // Nothing was restored, so nothing was restored *from*.
+ restoredFromRevisionId: null,
+ revisionId: null,
+ row: toRow(current),
+ version: versionOf(current),
+ };
+ }
+
+ const row = await guardedWrite(
+ tx,
+ id,
+ options.expectedVersion,
+ toColumnValues(
+ fields,
+ Object.fromEntries(changedFields.map(key => [key, patch[key]])),
+ ),
+ );
+ if (!row) return null;
+
+ const version = versionOf(row);
+ const newRevisionId = await capture(tx, {
+ actor: options.actor,
+ changedFields,
+ operation: "restore",
+ restoredFromRevisionId: revisionId,
+ row,
+ version,
+ });
+
+ return {
+ changed: true,
+ changedFields,
+ operation: "restore" as const,
+ previousSlug: slugOf(current),
+ restoredFromRevisionId: revisionId,
+ revisionId: newRevisionId,
+ row: toRow(row),
+ version,
+ };
+ }),
+
+ revisions,
+
+ schedules,
+
+ unpublish: async (id, options) =>
+ await transition(
+ id,
+ options,
+ "unpublish",
+ { status: "draft" },
+ eq(columns.status, "published"),
+ ),
+
+ update: async (id, values, options) =>
+ await transact(options, async tx => {
+ // Parsed before the row is read, so an invalid payload never costs a
+ // query. Slugs are normalised before the diff, so re-sending the stored
+ // slug in a different case counts as no change.
+ const patch = withUpdateSlugs(schemas.update.parse(values));
+
+ const current = await readOne(id, tx);
+ if (!current) return null;
+
+ const changedFields = diffChangedFields(fieldNames, current, patch);
+
+ // A no-op is still a *successful* write from the caller's point of view,
+ // but it must not bump the version or leave a revision: an editor who
+ // pressed save twice has not created two versions of anything. The stale
+ // `expectedVersion` is deliberately not checked here - there is nothing
+ // to overwrite, so there is nothing to conflict about.
+ if (changedFields.length === 0) {
+ return {
+ changed: false,
+ changedFields,
+ operation: "update" as const,
+ previousSlug: slugOf(current),
+ restoredFromRevisionId: null,
+ revisionId: null,
+ row: toRow(current),
+ version: versionOf(current),
+ };
+ }
+
+ const row = await guardedWrite(
+ tx,
+ id,
+ options.expectedVersion,
+ toColumnValues(
+ fields,
+ Object.fromEntries(changedFields.map(key => [key, patch[key]])),
+ ),
+ );
+ if (!row) return null;
+
+ const version = versionOf(row);
+ const revisionId = await capture(tx, {
+ actor: options.actor,
+ changedFields,
+ operation: "update",
+ row,
+ version,
+ });
+
+ return {
+ changed: true,
+ changedFields,
+ operation: "update" as const,
+ previousSlug: slugOf(current),
+ restoredFromRevisionId: null,
+ revisionId,
+ row: toRow(row),
+ version,
+ };
+ }),
+ };
+};
diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts
index c325f3b2b..1d4b10ff6 100644
--- a/packages/vitnode/src/content/server/emit.ts
+++ b/packages/vitnode/src/content/server/emit.ts
@@ -1,6 +1,10 @@
import type { Context } from "hono";
-import type { VitNodeEventName } from "../../api/models/events";
+import type {
+ EventEmitOptions,
+ EventEmitResult,
+ VitNodeEventName,
+} from "../../api/models/events";
import type {
ContentCreatedPayload,
ContentDeletedPayload,
@@ -31,7 +35,11 @@ type ContentPayload =
* autofixer and the build take turns breaking each other. This does not move.
*/
interface ContentEventEmitter {
- emit: (name: VitNodeEventName, payload: ContentPayload) => Promise;
+ emit: (
+ name: VitNodeEventName,
+ payload: ContentPayload,
+ options?: EventEmitOptions,
+ ) => Promise;
}
/**
@@ -47,15 +55,30 @@ interface ContentEventEmitter {
*
* Call it only once the database write has returned - never inside a
* transaction callback.
+ *
+ * The result is **returned, not swallowed**. `EventsModel.emit` never throws, so
+ * a listener that fell over is reported rather than raised - and a caller that
+ * only awaits this call has silently accepted whatever happened. Interactive
+ * routes are right to: the mutation committed and the person is owed a 200
+ * either way. The scheduled-effects task is not, and it reads `failures`.
*/
export const emitContentEvent = async (
c: Context,
definition: AnyContentTypeDefinition,
action: ContentEventAction,
payload: ContentPayload,
-): Promise => {
+ options?: {
+ /**
+ * The plugin that owns the content type - which is not always the plugin
+ * handling the request. A scheduled transition runs inside core's queue
+ * handler, and `content.example.article.published` belongs to the example
+ * plugin however it was triggered.
+ */
+ pluginId?: string;
+ },
+): Promise => {
const name = contentEventName(definition.id, action) as VitNodeEventName;
const events = c.get("events") as unknown as ContentEventEmitter;
- await events.emit(name, payload);
+ return await events.emit(name, payload, { pluginId: options?.pluginId });
};
diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts
index 2f00c4229..361bd9a22 100644
--- a/packages/vitnode/src/content/server/http-errors.ts
+++ b/packages/vitnode/src/content/server/http-errors.ts
@@ -1,7 +1,16 @@
import { HTTPException } from "hono/http-exception";
import { ZodError } from "zod";
-import { ContentInputError } from "../errors";
+import type { ContentConflict, ContentUnprocessable } from "../conflicts";
+import type { ContentScheduleCode } from "../schedules";
+
+import { CONTENT_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES } from "../const";
+import {
+ ContentInputError,
+ ContentRevisionNotRestorable,
+ ContentScheduleError,
+ ContentVersionConflict,
+} from "../errors";
/** Postgres error codes the engine translates into a useful HTTP status. */
const FOREIGN_KEY_VIOLATION = "23503";
@@ -26,17 +35,91 @@ const errorCode = (error: unknown, depth = 0): string | undefined => {
return errorCode(cause, depth + 1);
};
+/**
+ * A JSON error body, carried on the exception itself.
+ *
+ * `HTTPException` normally renders its `message` as text, but it also accepts a
+ * ready-made `Response` - and `app.onError` returns `error.getResponse()`
+ * verbatim, so the body survives untouched. That is what lets an editorial
+ * route answer a machine-readable 409 without a second error channel.
+ */
+const jsonError = (status: 400 | 409 | 422, body: unknown): HTTPException =>
+ new HTTPException(status, {
+ res: Response.json(body, { status }),
+ });
+
+/** A structured 409. Editorial content types only - see `zodContentConflict`. */
+export const contentConflict = (body: ContentConflict): HTTPException =>
+ jsonError(409, body);
+
+/** A structured 422, for a revision that no longer fits the content type. */
+export const contentUnprocessable = (
+ body: ContentUnprocessable,
+): HTTPException => jsonError(422, body);
+
+/**
+ * A structured 400, for a schedule the rules refuse.
+ *
+ * 400 rather than 409: nothing is in conflict, the request simply asked for a
+ * time that cannot work. The `code` is what lets the dialog point at the date
+ * field instead of raising a general error.
+ */
+export const contentScheduleRejected = (body: {
+ code: ContentScheduleCode;
+ contentTypeId: string;
+}): HTTPException => jsonError(400, body);
+
/**
* Turns a Postgres constraint failure into an HTTP response.
*
* The driver's message can name columns, constraints and even values, so it
* never reaches the client - only a generic sentence does. Anything unrecognised
* is rethrown for `app.onError`, which logs the detail and returns a bare 500.
+ *
+ * `structured` opts an editorial content type into JSON bodies for the two
+ * statuses a client has to branch on. It is off by default, so every Stage 1-3
+ * route answers exactly as it did before.
*/
export const rethrowAsHttpError = (
error: unknown,
- { action }: { action: "create" | "delete" | "update" },
+ {
+ action,
+ contentTypeId,
+ itemId,
+ structured = false,
+ }: {
+ action: "create" | "delete" | "update";
+ contentTypeId?: string;
+ itemId?: number;
+ structured?: boolean;
+ },
): never => {
+ if (error instanceof ContentVersionConflict) {
+ throw contentConflict({
+ code: CONTENT_CONFLICT_CODES.version,
+ contentTypeId: error.contentTypeId ?? contentTypeId ?? "",
+ currentVersion: error.currentVersion,
+ expectedVersion: error.expectedVersion,
+ itemId: error.itemId,
+ });
+ }
+
+ if (error instanceof ContentScheduleError) {
+ throw contentScheduleRejected({
+ code: error.code,
+ contentTypeId: error.contentTypeId ?? contentTypeId ?? "",
+ });
+ }
+
+ if (error instanceof ContentRevisionNotRestorable) {
+ throw contentUnprocessable({
+ code: CONTENT_UNPROCESSABLE_CODES.notRestorable,
+ contentTypeId: error.contentTypeId ?? contentTypeId ?? "",
+ fields: error.fields,
+ revisionId: error.revisionId,
+ });
+ }
+
// The service validates its own input, so a payload that slipped past the
// route's validator surfaces here. The issue tree stays out of the response:
// it names internal field paths, and the route schema already described the
@@ -66,24 +149,40 @@ export const rethrowAsHttpError = (
case NOT_NULL_VIOLATION:
throw new HTTPException(400, { message: "A required field is missing." });
case UNIQUE_VIOLATION:
- throw new HTTPException(409, {
- message: "A record with these values already exists.",
- });
+ // Same status either way; an editorial route just says it in a shape a
+ // client can branch on, alongside the version conflict it shares with.
+ throw structured
+ ? contentConflict({
+ code: CONTENT_CONFLICT_CODES.unique,
+ contentTypeId: contentTypeId ?? "",
+ itemId: itemId ?? null,
+ })
+ : new HTTPException(409, {
+ message: "A record with these values already exists.",
+ });
default:
throw error;
}
};
+export interface ContentHttpErrorOptions {
+ contentTypeId?: string;
+ itemId?: number;
+ /** Answer 409 and 422 with a JSON body. Editorial content types only. */
+ structured?: boolean;
+}
+
/** Runs a write and maps any constraint failure onto an HTTP status. */
export const withHttpErrors = async (
action: "create" | "delete" | "update",
run: () => Promise,
+ options: ContentHttpErrorOptions = {},
): Promise => {
try {
return await run();
} catch (error) {
if (error instanceof HTTPException) throw error;
- return rethrowAsHttpError(error, { action });
+ return rethrowAsHttpError(error, { action, ...options });
}
};
diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts
index 5af1d8153..c0b864870 100644
--- a/packages/vitnode/src/content/server/index.ts
+++ b/packages/vitnode/src/content/server/index.ts
@@ -6,20 +6,58 @@
* throws under plain Node, and both `apps/api` and `drizzle-kit` load these
* modules in plain Node.
*/
+export { CONTENT_SYSTEM_ACTOR, resolveContentActor } from "./actor";
export {
buildContentColumn,
+ buildEditorialColumns,
buildPublicationColumns,
buildSystemColumns,
} from "./column-builders";
export type { ColumnReferenceThunk } from "./column-builders";
+export { contentEditorialEffects } from "./editorial-effects";
+export type {
+ ContentEditorialEffectsOptions,
+ ContentEditorialEffectsResult,
+} from "./editorial-effects";
+export { createContentEditorialService } from "./editorial-service";
+export type {
+ ContentEditorialOptions,
+ ContentEditorialOutcome,
+ ContentEditorialPublicationOptions,
+ ContentEditorialService,
+ ContentEditorialWriteOptions,
+} from "./editorial-service";
export { emitContentEvent } from "./emit";
-export { rethrowAsHttpError, withHttpErrors } from "./http-errors";
-export { createContentModel } from "./model";
-export type { ContentModel } from "./model";
+export {
+ contentConflict,
+ contentUnprocessable,
+ rethrowAsHttpError,
+ withHttpErrors,
+} from "./http-errors";
+export type { ContentHttpErrorOptions } from "./http-errors";
+export { createContentModel, findContentModel } from "./model";
+export type {
+ AnyContentModel,
+ ContentModel,
+ RegisteredContentModel,
+} from "./model";
export { buildContentAdminModule } from "./module";
+export {
+ createContentPreviewToken,
+ verifyContentPreviewToken,
+ zodContentPreviewTokenPayload,
+} from "./preview-token";
+export type {
+ ContentPreviewToken,
+ ContentPreviewTokenPayload,
+} from "./preview-token";
export { buildContentPublicModule } from "./public-module";
export { buildContentPublicRoutes } from "./public-routes";
-export { createContentPublicService } from "./public-service";
+export {
+ contentPublicSelection,
+ createContentPublicProjector,
+ createContentPublicService,
+} from "./public-service";
export type {
ContentPublicFindManyArgs,
ContentPublicService,
@@ -40,7 +78,41 @@ export {
} from "./query";
export { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references";
export type { ReferenceTarget } from "./references";
+export {
+ contentRevisionSnapshot,
+ contentSnapshotRow,
+ projectRevisionSnapshot,
+} from "./revision-snapshot";
+export {
+ CONTENT_REVISIONS_DEFAULT_PAGE_SIZE,
+ CONTENT_REVISIONS_MAX_PAGE_SIZE,
+ createContentRevisionsModel,
+} from "./revisions-model";
+export type {
+ ContentRevisionCaptureInput,
+ ContentRevisionPage,
+ ContentRevisionsModel,
+} from "./revisions-model";
export { buildContentRoutes } from "./routes";
+export {
+ contentScheduleEffectsPayloadSchema,
+ runContentScheduleEffects,
+} from "./schedule-effects";
+export type {
+ ContentScheduleEffectsOutcome,
+ ContentScheduleEffectsPayload,
+} from "./schedule-effects";
+export {
+ claimContentSchedule,
+ createContentSchedulesModel,
+ pruneContentSchedules,
+ recordContentScheduleEffectsError,
+ settleContentSchedule,
+} from "./schedules-model";
+export type {
+ ClaimedContentSchedule,
+ ContentSchedulesModel,
+} from "./schedules-model";
export { contentSearchDocument } from "./search-document";
export { createContentSearchIndexer } from "./search-indexer";
export type { ContentSearchIndexer } from "./search-indexer";
@@ -64,11 +136,14 @@ export type {
ContentServiceOptions,
ContentUpdateResult,
} from "./service";
+export { createSlugNormalizer } from "./slugs";
+export type { ContentSlugNormalizer } from "./slugs";
export { contentTableColumns, createContentTable } from "./table";
export type {
ContentColumnBuilder,
ContentColumnBuilders,
ContentColumnName,
+ ContentEditorialColumnBuilders,
ContentPublicationColumnBuilders,
ContentReferences,
ContentSystemColumnBuilders,
diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts
index ebea0978d..04d8efc3a 100644
--- a/packages/vitnode/src/content/server/model.ts
+++ b/packages/vitnode/src/content/server/model.ts
@@ -3,6 +3,7 @@ import type { Context } from "hono";
import type { ContentSchemas } from "../schemas";
import type { AnyContentTypeDefinition } from "../types";
+import type { ContentEditorialService } from "./editorial-service";
import type { ContentPublicService } from "./public-service";
import type { ContentService } from "./service";
import type {
@@ -11,6 +12,7 @@ import type {
ContentTableFor,
} from "./types";
+import { createContentEditorialService } from "./editorial-service";
import { createContentPublicService } from "./public-service";
import { createContentService } from "./service";
import { contentTableColumns, createContentTable } from "./table";
@@ -19,6 +21,20 @@ export interface ContentModel {
/** Column name -> Drizzle column, for filters, ordering and custom queries. */
columns: Record, PgColumn>;
definition: TDefinition;
+ /**
+ * The transactional editorial repository, or `undefined` when the content
+ * type has no `editorial` block.
+ *
+ * `undefined` rather than a throwing stub, for the same reason
+ * `publicService` is: the check reads naturally in a route builder that has
+ * no idea which content type it was handed.
+ */
+ editorialService:
+ | ((
+ c: Context,
+ options: { pluginId: string },
+ ) => ContentEditorialService)
+ | undefined;
/**
* The read-only public repository, or `undefined` when the content type has
* no `publicApi`.
@@ -36,6 +52,35 @@ export interface ContentModel {
table: ContentTableFor;
}
+/**
+ * Any content model, for code that holds a collection of them.
+ *
+ * The same shape `AnyContentTypeDefinition` provides for definitions, and it
+ * exists for the same reason: background work - the scheduled-publication task,
+ * the cleanup cron - looks a model up by content type id and cannot know which
+ * concrete one it will get.
+ */
+export type AnyContentModel = ContentModel;
+
+/**
+ * A model plus the plugin that registered it.
+ *
+ * The owner is not on the model itself because `createContentModel` is called
+ * from `src/database/.ts`, which has no reason to know it. It is
+ * attached here, at collection time, where `buildApiPlugin` already knows.
+ */
+export interface RegisteredContentModel {
+ model: AnyContentModel;
+ pluginId: string;
+}
+
+/** Finds the model for one content type id, or `undefined`. */
+export const findContentModel = (
+ models: readonly RegisteredContentModel[],
+ contentTypeId: string,
+): RegisteredContentModel | undefined =>
+ models.find(entry => entry.model.definition.id === contentTypeId);
+
/**
* Turns a content type definition into its database model.
*
@@ -72,6 +117,22 @@ export const createContentModel = <
return {
columns,
definition,
+ // The plugin id arrives at call time rather than being captured here: a
+ // revision is stamped with its owner, and `createContentModel` is called
+ // from `src/database/*.ts`, which does not otherwise need to know it. Every
+ // caller - the generated routes, the queue handler - already carries it,
+ // the same way `createContentSearchIndexer` receives it.
+ editorialService: definition.editorial.enabled
+ ? (c: Context, { pluginId }: { pluginId: string }) =>
+ createContentEditorialService({
+ c,
+ columns,
+ definition,
+ pluginId,
+ schemas,
+ table,
+ })
+ : undefined,
publicService: definition.publicApi.enabled
? (c: Context) =>
createContentPublicService({ c, columns, definition, table })
diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts
index af9eb837f..08265a407 100644
--- a/packages/vitnode/src/content/server/module.ts
+++ b/packages/vitnode/src/content/server/module.ts
@@ -55,6 +55,10 @@ export const buildContentAdminModule = ({
routes: [],
modules,
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,
+ // and it runs in a cron request that knows nothing but a content type id.
+ contentModels: contentTypes,
// A content type without `search` contributes nothing, so the two module
// builders can keep taking the same array. The plugin id travels with the
// indexer so a rebuild - which runs in the core cron request - still stores
diff --git a/packages/vitnode/src/content/server/preview-config.test.ts b/packages/vitnode/src/content/server/preview-config.test.ts
new file mode 100644
index 000000000..ea7e8696b
--- /dev/null
+++ b/packages/vitnode/src/content/server/preview-config.test.ts
@@ -0,0 +1,199 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ testEditorialNoteContentType,
+ testEditorialPostContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import { INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET } from "../../lib/config";
+import {
+ assertContentPreviewConfig,
+ contentPreviewConfigProblems,
+ contentPreviewSecretProblem,
+} from "./preview-config";
+
+const STRONG = "unit-test-content-preview-secret-0123456789";
+
+/** `testEditorialPostContentType` is the only fixture with preview enabled. */
+const previewable = [
+ { definition: testEditorialPostContentType, pluginId: "@vitnode/example" },
+];
+const withoutPreview = [
+ { definition: testPostContentType, pluginId: "@vitnode/example" },
+ { definition: testEditorialNoteContentType, pluginId: "@vitnode/example" },
+];
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("contentPreviewSecretProblem", () => {
+ it("accepts 32 random-looking bytes", () => {
+ expect(contentPreviewSecretProblem(STRONG)).toBeNull();
+ });
+
+ it("rejects a missing secret", () => {
+ expect(contentPreviewSecretProblem(undefined)).toMatch(/not set/);
+ expect(contentPreviewSecretProblem("")).toMatch(/not set/);
+ });
+
+ it("rejects the fallback that ships in the source", () => {
+ // The whole reason this check exists: the value is public, so a token
+ // signed with it is a token anyone can sign.
+ expect(
+ contentPreviewSecretProblem(INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET),
+ ).toMatch(/placeholder/);
+ });
+
+ it("rejects a secret short enough to attack", () => {
+ expect(contentPreviewSecretProblem("hunter2")).toMatch(/shorter than 32/);
+ // 31 bytes: one short, and still refused.
+ expect(contentPreviewSecretProblem("a".repeat(31))).toMatch(
+ /shorter than 32/,
+ );
+ expect(contentPreviewSecretProblem("a".repeat(32))).toBeNull();
+ });
+
+ it("counts bytes rather than characters", () => {
+ // 16 emoji is 16 characters and 64 bytes. Counting characters would have
+ // rejected it; counting bytes is what the key length actually is.
+ expect(contentPreviewSecretProblem("🔐".repeat(16))).toBeNull();
+ expect(contentPreviewSecretProblem("🔐".repeat(4))).toMatch(/shorter/);
+ });
+});
+
+describe("contentPreviewConfigProblems", () => {
+ it("is empty for a good secret and parseable origins", () => {
+ expect(contentPreviewConfigProblems(STRONG)).toEqual([]);
+ });
+
+ it("reports an unparseable web origin", () => {
+ // A preview link resolved against this would not be a link.
+ vi.stubEnv("NEXT_PUBLIC_WEB_URL", "not a url");
+
+ expect(contentPreviewConfigProblems(STRONG)).toEqual([
+ expect.stringContaining("NEXT_PUBLIC_WEB_URL"),
+ ]);
+
+ vi.unstubAllEnvs();
+ });
+
+ it("reports an unparseable API origin", () => {
+ vi.stubEnv("NEXT_PUBLIC_API_URL", "");
+
+ expect(contentPreviewConfigProblems(STRONG)).toEqual([
+ expect.stringContaining("NEXT_PUBLIC_API_URL"),
+ ]);
+
+ vi.unstubAllEnvs();
+ });
+});
+
+describe("assertContentPreviewConfig", () => {
+ it("says nothing when no content type can be previewed", () => {
+ // Nothing signs anything, so there is nothing to secure.
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: withoutPreview,
+ isProduction: true,
+ secret: undefined,
+ }),
+ ).not.toThrow();
+ });
+
+ it("boots happily with a real secret", () => {
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ isProduction: true,
+ secret: STRONG,
+ }),
+ ).not.toThrow();
+ });
+
+ it.each([
+ ["missing", undefined],
+ ["the published fallback", INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET],
+ ["too short", "hunter2"],
+ ])("refuses to boot production when the secret is %s", (_, secret) => {
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ isProduction: true,
+ secret,
+ }),
+ ).toThrow(/CONTENT_PREVIEW_SECRET/);
+ });
+
+ it("names the content types that made it mandatory", () => {
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ isProduction: true,
+ secret: undefined,
+ }),
+ ).toThrow(/test\.editorial/);
+ });
+
+ it("tells the reader how to generate one", () => {
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ isProduction: true,
+ secret: undefined,
+ }),
+ ).toThrow(/openssl rand/);
+ });
+
+ it("lets `next build` collect page data without the secret", () => {
+ // Next imports every route module during a production build, so the API's
+ // boot code runs on a machine that has no business holding a signing key.
+ // The serving process still refuses to start, which is where it matters.
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("NEXT_PHASE", "phase-production-build");
+
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ secret: undefined,
+ }),
+ ).not.toThrow();
+ expect(warn).toHaveBeenCalled();
+
+ vi.unstubAllEnvs();
+ });
+
+ it("still refuses a production process that is actually serving", () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("NEXT_PHASE", "phase-production-server");
+
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ secret: undefined,
+ }),
+ ).toThrow(/CONTENT_PREVIEW_SECRET/);
+
+ vi.unstubAllEnvs();
+ });
+
+ it("warns instead of throwing outside production", () => {
+ // `pnpm dev` should still start. Preview itself stays switched off - the
+ // routes fail closed - but a local database is not a reason to refuse boot.
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ expect(() =>
+ assertContentPreviewConfig({
+ contentTypes: previewable,
+ isProduction: false,
+ secret: undefined,
+ }),
+ ).not.toThrow();
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("CONTENT_PREVIEW_SECRET"),
+ );
+ });
+});
diff --git a/packages/vitnode/src/content/server/preview-config.ts b/packages/vitnode/src/content/server/preview-config.ts
new file mode 100644
index 000000000..7c66cdb23
--- /dev/null
+++ b/packages/vitnode/src/content/server/preview-config.ts
@@ -0,0 +1,124 @@
+import type { RegisteredContentType } from "../registry";
+
+import {
+ CONFIG,
+ CONTENT_PREVIEW_SECRET_MIN_BYTES,
+ INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET,
+ isSecureContentPreviewSecret,
+} from "../../lib/config";
+import { ContentEngineError } from "../errors";
+
+/**
+ * The sentence a person needs to fix an unusable preview secret.
+ *
+ * `null` when the secret is fine. Three distinct reasons rather than one,
+ * because "you have not set it" and "you set it to twelve characters" call for
+ * different reactions, and a single "misconfigured" would hide which.
+ */
+export const contentPreviewSecretProblem = (
+ secret: null | string | undefined,
+): null | string => {
+ if (isSecureContentPreviewSecret(secret)) return null;
+
+ if (secret === undefined || secret === null || secret === "") {
+ return "CONTENT_PREVIEW_SECRET is not set.";
+ }
+
+ if (secret === INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET) {
+ return "CONTENT_PREVIEW_SECRET is still the built-in placeholder, which is published in the VitNode source.";
+ }
+
+ return `CONTENT_PREVIEW_SECRET is shorter than ${CONTENT_PREVIEW_SECRET_MIN_BYTES} bytes.`;
+};
+
+/** Whether a configured origin is a URL the preview link builder can use. */
+const originProblem = (name: string, read: () => URL): null | string => {
+ try {
+ read();
+
+ return null;
+ } catch {
+ return `${name} is not a valid absolute URL, so preview links cannot be built.`;
+ }
+};
+
+/**
+ * Everything standing between this install and a working preview link.
+ *
+ * Both halves matter and both are checked here rather than at the point of use:
+ * an unusable secret means anyone can mint their own token, and an unparseable
+ * `NEXT_PUBLIC_WEB_URL` means the link that comes back is not a link.
+ */
+export const contentPreviewConfigProblems = (
+ secret: null | string | undefined,
+): string[] => {
+ const problems = [
+ contentPreviewSecretProblem(secret),
+ originProblem("NEXT_PUBLIC_WEB_URL", () => CONFIG.web),
+ originProblem("NEXT_PUBLIC_API_URL", () => CONFIG.api),
+ ];
+
+ return problems.filter((problem): problem is string => problem !== null);
+};
+
+const HOW_TO_FIX =
+ "Generate one with `openssl rand -base64 32` (or `node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64'))\"`) and set it on every process that serves the API.";
+
+/**
+ * Whether this process is `next build` collecting page data rather than a
+ * server about to answer requests.
+ *
+ * Next imports every route module during a production build, so the API's boot
+ * code runs there too - and a build machine has no business holding a runtime
+ * signing secret. Failing the build would push every install to bake its
+ * secrets into an image, which is a worse outcome than the one being prevented.
+ * The serving process still refuses to start, which is where it matters.
+ */
+const isBuildPhase = (): boolean =>
+ process.env.NEXT_PHASE === "phase-production-build";
+
+/**
+ * Refuses to boot a production install whose preview links would be forgeable.
+ *
+ * Called once, after every plugin's content types are known, because "is
+ * preview enabled anywhere" is not answerable before that. An install with no
+ * previewable content type is unaffected - there is nothing to sign.
+ *
+ * **Production refuses to start; development starts with preview switched
+ * off.** The reasoning is the same in both cases and only the blast radius
+ * differs: a signature is the *entire* access control on a preview link, so a
+ * well-known secret is not a warning, it is unpublished content served to
+ * anyone who reads the VitNode source. Failing at deploy time is far kinder
+ * than shipping a feature that quietly hands drafts out; failing at `pnpm dev`
+ * time would be rude, so there the routes fail closed instead and say why.
+ */
+export const assertContentPreviewConfig = ({
+ contentTypes,
+ isProduction = process.env.NODE_ENV === "production" && !isBuildPhase(),
+ secret,
+}: {
+ contentTypes: RegisteredContentType[];
+ isProduction?: boolean;
+ secret: null | string | undefined;
+}): void => {
+ const previewable = contentTypes.filter(
+ entry => entry.definition.editorial.preview.enabled,
+ );
+ if (previewable.length === 0) return;
+
+ const problems = contentPreviewConfigProblems(secret);
+ if (problems.length === 0) return;
+
+ const names = previewable.map(entry => entry.definition.id).join(", ");
+ const message = `${names} ${previewable.length === 1 ? "has" : "have"} \`editorial.preview\` enabled, but preview is not safe to serve: ${problems.join(" ")} ${HOW_TO_FIX}`;
+
+ if (isProduction) throw new ContentEngineError(message);
+
+ // Not fatal outside a serving production process, but not silent either:
+ // without this the only symptom is a 503 from a button somebody clicks three
+ // days later.
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[Content Engine] ${message} Preview stays disabled until then.`,
+ );
+};
diff --git a/packages/vitnode/src/content/server/preview-route.test.ts b/packages/vitnode/src/content/server/preview-route.test.ts
new file mode 100644
index 000000000..01fee88a3
--- /dev/null
+++ b/packages/vitnode/src/content/server/preview-route.test.ts
@@ -0,0 +1,310 @@
+// @vitest-environment node
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { testEditorialPostContentType } from "@/tests/content-fixtures";
+
+import type { ContentRevisionSnapshot } from "../revisions";
+
+import { INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET } from "../../lib/config";
+import { createContentModel } from "./model";
+import { createContentPreviewToken } from "./preview-token";
+import { buildContentPublicRoutes } from "./public-routes";
+
+const PLUGIN_ID = "@vitnode/example";
+// Long enough to be a real signing key: the preview routes fail closed on a
+// secret that is missing, well-known or under 32 bytes, so a short one here
+// would test the guard rather than the route.
+const SECRET = "unit-test-content-preview-secret-0123456789";
+
+const posts = createContentModel(testEditorialPostContentType);
+
+const snapshot = (
+ overrides?: Partial,
+): ContentRevisionSnapshot => ({
+ contentTypeId: testEditorialPostContentType.id,
+ createdAt: "2026-08-01T09:00:00.000Z",
+ fields: {
+ excerpt: "Not published yet",
+ slug: "hello-world",
+ title: "Hello world",
+ // Private: absent from `publicApi.fields`, so it must never reach a body.
+ views: 4242,
+ },
+ id: 7,
+ publication: { publishedAt: null, status: "draft" },
+ schemaVersion: 1,
+ updatedAt: "2026-08-02T09:00:00.000Z",
+ version: 3,
+ ...overrides,
+});
+
+/**
+ * Mounts the generated public routes with the editorial service and the
+ * database stubbed.
+ *
+ * No session middleware and no admin context: the request arrives exactly as an
+ * anonymous reviewer's would, which is the only way this route is ever used.
+ */
+const harness = ({ secret = SECRET }: { secret?: string } = {}) => {
+ const findById = vi.fn();
+ const selections: Record[] = [];
+ const liveRows: Record[] = [];
+
+ const db = {
+ select: (selection: Record) => {
+ selections.push(selection);
+
+ return {
+ from: () => ({
+ where: () => ({ limit: async () => Promise.resolve(liveRows) }),
+ }),
+ };
+ },
+ };
+
+ vi.spyOn(posts, "editorialService", "get").mockReturnValue(
+ () => ({ revisions: { findById } }) as never,
+ );
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("db", db as never);
+ c.set("core", { contentPreviewSecret: secret } as never);
+ await next();
+ });
+ for (const { handler, route } of buildContentPublicRoutes(posts, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, findById, liveRows, selections };
+};
+
+const mint = (overrides?: { itemId?: number; revisionId?: number }) =>
+ createContentPreviewToken({
+ definition: testEditorialPostContentType,
+ itemId: overrides?.itemId ?? 7,
+ pluginId: PLUGIN_ID,
+ revisionId: overrides?.revisionId ?? 42,
+ secret: SECRET,
+ version: 3,
+ }).token;
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("the public preview route", () => {
+ it("returns an unpublished record to a caller with no session", async () => {
+ const { app, findById } = harness();
+ findById.mockResolvedValue({ snapshot: snapshot() });
+
+ const res = await app.request(`/preview/${mint()}`);
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ excerpt: "Not published yet",
+ publishedAt: null,
+ slug: "hello-world",
+ title: "Hello world",
+ });
+ });
+
+ it("never returns a private field", async () => {
+ // The fixture's `views` is deliberately absent from `publicApi.fields`. If
+ // the preview projected the snapshot itself instead of going through
+ // `createContentPublicProjector`, this is the test that would catch it.
+ const { app, findById } = harness();
+ findById.mockResolvedValue({ snapshot: snapshot() });
+
+ const body = await (await app.request(`/preview/${mint()}`)).json();
+
+ expect(body).not.toHaveProperty("views");
+ expect(JSON.stringify(body)).not.toContain("4242");
+ });
+
+ it("marks the response private and unindexable", async () => {
+ const { app, findById } = harness();
+ findById.mockResolvedValue({ snapshot: snapshot() });
+
+ const res = await app.request(`/preview/${mint()}`);
+
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
+ expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
+ });
+
+ it("asks for the revision scoped by the record in the token", async () => {
+ const { app, findById } = harness();
+ findById.mockResolvedValue({ snapshot: snapshot() });
+
+ await app.request(`/preview/${mint({ itemId: 7, revisionId: 42 })}`);
+
+ // Both arguments, always: the revisions table is shared, so a revision id
+ // on its own proves nothing about which record it belongs to.
+ expect(findById).toHaveBeenCalledWith(7, 42);
+ });
+
+ it("reads the live row when the record has no revision", async () => {
+ const { app, findById, liveRows, selections } = harness();
+ liveRows.push({
+ excerpt: "Never edited since editorial was enabled",
+ id: 7,
+ publishedAt: null,
+ slug: "hello-world",
+ title: "Hello world",
+ });
+
+ const res = await app.request(`/preview/${mint({ revisionId: 0 })}`);
+
+ expect(res.status).toBe(200);
+ expect(findById).not.toHaveBeenCalled();
+ // Even on the live path the SELECT is the public allowlist plus the cursor,
+ // so a private column is never fetched in the first place.
+ expect(Object.keys(selections[0]).sort()).toEqual([
+ "excerpt",
+ "id",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ });
+
+ it.each([
+ ["a forged signature", "eyJhIjoxfQ.bm90LWEtc2lnbmF0dXJl"],
+ ["garbage", "not-a-token"],
+ ["an empty token", "%20"],
+ ])("answers 404 for %s", async (_name, token) => {
+ const { app } = harness();
+
+ expect((await app.request(`/preview/${token}`)).status).toBe(404);
+ });
+
+ it("answers 404 when the revision is gone", async () => {
+ // Pruned by retention, or the record was deleted. Same 404 as a forged
+ // token, deliberately - the reviewer learns nothing either way.
+ const { app, findById } = harness();
+ findById.mockResolvedValue(null);
+
+ expect((await app.request(`/preview/${mint()}`)).status).toBe(404);
+ });
+
+ it("answers 404 when a live-row token points at nothing", async () => {
+ const { app } = harness();
+
+ expect(
+ (await app.request(`/preview/${mint({ revisionId: 0 })}`)).status,
+ ).toBe(404);
+ });
+
+ it("answers 404 for a token signed with another secret", async () => {
+ const { app } = harness();
+ const token = createContentPreviewToken({
+ definition: testEditorialPostContentType,
+ itemId: 7,
+ pluginId: PLUGIN_ID,
+ revisionId: 42,
+ secret: "someone-elses-secret",
+ version: 3,
+ }).token;
+
+ expect((await app.request(`/preview/${token}`)).status).toBe(404);
+ });
+
+ describe("an install that cannot protect its links", () => {
+ const forged = (secret: string) =>
+ createContentPreviewToken({
+ definition: testEditorialPostContentType,
+ itemId: 7,
+ pluginId: PLUGIN_ID,
+ revisionId: 42,
+ secret,
+ version: 3,
+ }).token;
+
+ it.each([
+ ["the published fallback", INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET],
+ ["a secret short enough to attack", "hunter2"],
+ ["no secret at all", ""],
+ ])("refuses a token forged with %s", async (_name, secret) => {
+ // The attack the fail-closed rule exists for: the fallback is in the
+ // published source, so an attacker signs `{ i: 7, r: 0 }` themselves and
+ // reads unpublished rows by walking the ids. The route does not honour
+ // *any* token while the secret is unusable, so the forgery is worthless.
+ const { app, findById, liveRows } = harness({ secret });
+ findById.mockResolvedValue({ snapshot: snapshot() });
+ liveRows.push({ id: 7, title: "Hello world" });
+
+ const res = await app.request(`/preview/${forged(secret)}`);
+
+ expect(res.status).toBe(404);
+ // Nothing was even looked up: no oracle, and no wasted query.
+ expect(findById).not.toHaveBeenCalled();
+ });
+
+ it("answers exactly like a bad token, so the misconfiguration is invisible", async () => {
+ const broken = harness({
+ secret: INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET,
+ });
+ const working = harness();
+
+ const bodies = await Promise.all([
+ (
+ await broken.app.request(
+ `/preview/${forged(INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET)}`,
+ )
+ ).text(),
+ (await working.app.request("/preview/not-a-token")).text(),
+ ]);
+
+ expect(new Set(bodies).size).toBe(1);
+ });
+ });
+
+ it("says nothing different for any of them", async () => {
+ const { app, findById } = harness();
+ findById.mockResolvedValue(null);
+
+ const bodies = await Promise.all(
+ ["not-a-token", mint(), mint({ itemId: 999 })].map(async token =>
+ (await app.request(`/preview/${token}`)).text(),
+ ),
+ );
+
+ // A distinguishable message is a record-existence oracle, which is the one
+ // thing a draft URL must not be.
+ expect(new Set(bodies).size).toBe(1);
+ });
+});
+
+describe("route registration", () => {
+ it("declares no staff permission, deliberately", () => {
+ // The signed, expiring token *is* the authorization. Asserted rather than
+ // assumed, because adding one would silently break every preview link and
+ // removing one elsewhere must never look like this.
+ const routes = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID });
+ const preview = routes.find(
+ entry => entry.route.path === "/preview/{token}",
+ );
+
+ expect(preview).toBeDefined();
+ expect(preview).not.toHaveProperty("adminStaffPermission");
+ });
+
+ it("cannot shadow a record whose slug is literally 'preview'", async () => {
+ const { app } = harness();
+ const service = {
+ findById: vi.fn(),
+ findBySlug: vi.fn(),
+ findMany: vi.fn(),
+ };
+ vi.spyOn(posts, "publicService", "get").mockReturnValue(() => service);
+ service.findBySlug.mockResolvedValue({ slug: "preview", title: "Preview" });
+
+ const res = await app.request("/preview");
+
+ expect(res.status).toBe(200);
+ expect(service.findBySlug).toHaveBeenCalledWith("preview");
+ });
+});
diff --git a/packages/vitnode/src/content/server/preview-token.test.ts b/packages/vitnode/src/content/server/preview-token.test.ts
new file mode 100644
index 000000000..656b47f48
--- /dev/null
+++ b/packages/vitnode/src/content/server/preview-token.test.ts
@@ -0,0 +1,147 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import {
+ testEditorialNoteContentType,
+ testEditorialPostContentType,
+} from "../../tests/content-fixtures";
+import {
+ createContentPreviewToken,
+ verifyContentPreviewToken,
+} from "./preview-token";
+
+// Long enough to be a real signing key: the preview routes fail closed on a
+// secret that is missing, well-known or under 32 bytes, so a short one here
+// would test the guard rather than the route.
+const SECRET = "unit-test-content-preview-secret-0123456789";
+const PLUGIN = "@vitnode/test";
+
+const NOW = new Date("2026-08-05T10:00:00.000Z");
+
+const mint = (overrides?: {
+ definition?: typeof testEditorialPostContentType;
+ itemId?: number;
+ now?: Date;
+ pluginId?: string;
+ revisionId?: number;
+ secret?: string;
+}) =>
+ createContentPreviewToken({
+ definition: overrides?.definition ?? testEditorialPostContentType,
+ itemId: overrides?.itemId ?? 7,
+ now: overrides?.now ?? NOW,
+ pluginId: overrides?.pluginId ?? PLUGIN,
+ revisionId: overrides?.revisionId ?? 42,
+ secret: overrides?.secret ?? SECRET,
+ version: 3,
+ });
+
+const verify = (token: string, now = NOW) =>
+ verifyContentPreviewToken({
+ definition: testEditorialPostContentType,
+ now,
+ pluginId: PLUGIN,
+ secret: SECRET,
+ token,
+ });
+
+describe("createContentPreviewToken", () => {
+ it("expires after the content type's configured window", () => {
+ // The fixture asks for 30 minutes rather than the default 15, so this also
+ // proves the config is read rather than the constant.
+ expect(
+ testEditorialPostContentType.editorial.preview.expiresInMinutes,
+ ).toBe(30);
+ expect(mint().expiresAt.toISOString()).toBe("2026-08-05T10:30:00.000Z");
+ });
+
+ it("binds the record and its revision", () => {
+ const payload = verify(mint().token);
+
+ expect(payload).toMatchObject({ i: 7, p: PLUGIN, r: 42, ver: 3 });
+ expect(payload?.t).toBe(testEditorialPostContentType.id);
+ });
+});
+
+describe("verifyContentPreviewToken", () => {
+ it("accepts a fresh token", () => {
+ expect(verify(mint().token)).not.toBeNull();
+ });
+
+ it("rejects it once it has expired", () => {
+ const { token } = mint();
+
+ expect(verify(token, new Date("2026-08-05T10:29:59.000Z"))).not.toBeNull();
+ // No leeway at all: the boundary is the boundary.
+ expect(verify(token, new Date("2026-08-05T10:30:00.000Z"))).toBeNull();
+ expect(verify(token, new Date("2026-08-05T11:00:00.000Z"))).toBeNull();
+ });
+
+ it("rejects a token signed with another secret", () => {
+ expect(verify(mint({ secret: "other-secret" }).token)).toBeNull();
+ });
+
+ it("rejects a token minted for another plugin", () => {
+ // The signature is valid - the *scope* is not. Without this check one
+ // signed token would work on every preview route in the install.
+ expect(verify(mint({ pluginId: "@vitnode/other" }).token)).toBeNull();
+ });
+
+ it("rejects a token minted for another content type", () => {
+ const { token } = createContentPreviewToken({
+ definition: testEditorialNoteContentType,
+ itemId: 7,
+ now: NOW,
+ pluginId: PLUGIN,
+ revisionId: 42,
+ secret: SECRET,
+ version: 3,
+ });
+
+ expect(verify(token)).toBeNull();
+ });
+
+ it("rejects a tampered payload", () => {
+ const { token } = mint();
+ const [, signature] = token.split(".");
+ const forged = Buffer.from(
+ JSON.stringify({
+ aud: "content-preview",
+ exp: Math.floor(NOW.getTime() / 1000) + 3600,
+ i: 999,
+ p: PLUGIN,
+ r: 1,
+ t: testEditorialPostContentType.id,
+ v: 1,
+ ver: 1,
+ }),
+ "utf8",
+ ).toString("base64url");
+
+ expect(verify(`${forged}.${signature}`)).toBeNull();
+ });
+
+ it.each([
+ ["empty", ""],
+ ["garbage", "not-a-token"],
+ ["only a separator", "."],
+ ["truncated", "eyJhdWQiOiJjb250ZW50LXByZXZpZXcifQ"],
+ ])("rejects %s input without throwing", (_name, token) => {
+ expect(() => verify(token)).not.toThrow();
+ expect(verify(token)).toBeNull();
+ });
+
+ it("keeps every failure indistinguishable", () => {
+ // The route answers 404 for all of them, so the function must not hand it
+ // anything it could accidentally branch on. One value, every time.
+ const failures = [
+ verify(""),
+ verify("garbage"),
+ verify(mint({ secret: "other" }).token),
+ verify(mint({ pluginId: "@vitnode/other" }).token),
+ verify(mint().token, new Date("2026-08-06T00:00:00.000Z")),
+ ];
+
+ expect(failures).toEqual([null, null, null, null, null]);
+ });
+});
diff --git a/packages/vitnode/src/content/server/preview-token.ts b/packages/vitnode/src/content/server/preview-token.ts
new file mode 100644
index 000000000..45da24be4
--- /dev/null
+++ b/packages/vitnode/src/content/server/preview-token.ts
@@ -0,0 +1,125 @@
+import { z } from "zod";
+
+import type { AnyContentTypeDefinition } from "../types";
+
+import { signPayload, verifySignedPayload } from "../../lib/api/signed-token";
+import { CONTENT_PREVIEW_TOKEN_VERSION } from "../const";
+
+/**
+ * What a preview link carries, in short keys because it travels in a URL.
+ *
+ * `r` is the load-bearing one: a token is bound to **one revision**, so a
+ * reviewer sees the state the editor was looking at when they shared the link,
+ * not whatever the record has drifted to since. `0` means the record had no
+ * revision yet - a row that predates its content type opting into `editorial` -
+ * and the live row is read instead.
+ *
+ * `ver` is the row version at issue time. Nothing branches on it; it is there
+ * so a support conversation about "which version did they see" has an answer
+ * even after the revision was pruned.
+ */
+export const zodContentPreviewTokenPayload = z.object({
+ /** Rejects a token minted for anything else that ever shares this secret. */
+ aud: z.literal("content-preview"),
+ /** Epoch **seconds**, not milliseconds. */
+ exp: z.number().int().positive(),
+ i: z.number().int().positive(),
+ p: z.string().min(1),
+ r: z.number().int().nonnegative(),
+ t: z.string().min(1),
+ v: z.literal(CONTENT_PREVIEW_TOKEN_VERSION),
+ ver: z.number().int().positive(),
+});
+
+export type ContentPreviewTokenPayload = z.infer<
+ typeof zodContentPreviewTokenPayload
+>;
+
+export interface ContentPreviewToken {
+ expiresAt: Date;
+ token: string;
+}
+
+/**
+ * Mints a preview link for one revision of one record.
+ *
+ * The expiry is absolute and has **no leeway** on the way back in. Web and API
+ * already need agreeing clocks for sessions to work at all, and slack on an
+ * expiry only ever weakens it.
+ */
+export const createContentPreviewToken = ({
+ definition,
+ itemId,
+ now = new Date(),
+ pluginId,
+ revisionId,
+ secret,
+ version,
+}: {
+ definition: AnyContentTypeDefinition;
+ itemId: number;
+ now?: Date;
+ pluginId: string;
+ /** `0` when the record has no revision to freeze. */
+ revisionId: number;
+ secret: string;
+ version: number;
+}): ContentPreviewToken => {
+ const expiresAt = new Date(
+ now.getTime() + definition.editorial.preview.expiresInMinutes * 60_000,
+ );
+
+ const payload: ContentPreviewTokenPayload = {
+ aud: "content-preview",
+ exp: Math.floor(expiresAt.getTime() / 1000),
+ i: itemId,
+ p: pluginId,
+ r: revisionId,
+ t: definition.id,
+ v: CONTENT_PREVIEW_TOKEN_VERSION,
+ ver: version,
+ };
+
+ return { expiresAt, token: signPayload(secret, payload) };
+};
+
+/**
+ * Reads a preview link back, or returns `null`.
+ *
+ * One return value for every failure - bad signature, wrong secret, expired,
+ * truncated, minted for another plugin, another content type, or another
+ * record. The caller answers 404 for all of them, because a 401 or a 403 would
+ * confirm that the record exists, which is the single thing a draft URL must
+ * never do.
+ *
+ * The plugin and content type are checked here rather than trusted from the
+ * payload: the route knows which definition it is serving, and a token is only
+ * valid for *that* one. Without this, one signed token would work on every
+ * preview route in the install.
+ */
+export const verifyContentPreviewToken = ({
+ definition,
+ now = new Date(),
+ pluginId,
+ secret,
+ token,
+}: {
+ definition: AnyContentTypeDefinition;
+ now?: Date;
+ pluginId: string;
+ secret: string;
+ token: string;
+}): ContentPreviewTokenPayload | null => {
+ const payload = verifySignedPayload(
+ secret,
+ token,
+ zodContentPreviewTokenPayload,
+ );
+ if (!payload) return null;
+
+ if (payload.p !== pluginId) return null;
+ if (payload.t !== definition.id) return null;
+ if (payload.exp * 1000 <= now.getTime()) return null;
+
+ return payload;
+};
diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts
index 2e12f751c..66ef5507d 100644
--- a/packages/vitnode/src/content/server/public-routes.ts
+++ b/packages/vitnode/src/content/server/public-routes.ts
@@ -1,6 +1,8 @@
+import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core";
import type { Context } from "hono";
import { z } from "@hono/zod-openapi";
+import { eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import type {
@@ -9,6 +11,7 @@ import type {
ContentPublicOrderableFieldName,
} from "../types";
import type { ContentModel } from "./model";
+import type { ContentPreviewTokenPayload } from "./preview-token";
import type { ContentPublicService } from "./public-service";
import { buildRoute } from "../../api/lib/route";
@@ -16,16 +19,24 @@ import {
zodPaginationPageInfo,
zodPaginationQuery,
} from "../../api/lib/with-pagination";
+import { CONFIG, isSecureContentPreviewSecret } from "../../lib/config";
import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const";
import { ContentEngineError } from "../errors";
import { publicOrderableColumns } from "../registry";
+import { verifyContentPreviewToken } from "./preview-token";
+import {
+ contentPublicSelection,
+ createContentPublicProjector,
+} from "./public-service";
+import { contentSnapshotRow } from "./revision-snapshot";
/**
- * The two read-only routes one public content type gets.
+ * The read-only routes one public content type gets.
*
* ```http
* GET /api/{pluginId}/content/{publicApi.path}/
* GET /api/{pluginId}/content/{publicApi.path}/{slug}
+ * GET /api/{pluginId}/content/{publicApi.path}/preview/{token} (editorial.preview)
* ```
*
* No `adminStaffPermission` and no `/admin/` anywhere in the path, which is
@@ -74,6 +85,59 @@ export const buildContentPublicRoutes = <
message: `${label.singular} not found.`,
});
+ const project = createContentPublicProjector(definition);
+
+ // Widened, not cast: the generated table type carries every column as a
+ // literal, which Drizzle's `.from()` overloads cannot resolve through a
+ // generic. This is the same parameter type `createContentPublicService`
+ // declares, so the assignment is checked rather than asserted.
+ const table: PgTableWithColumns = model.table;
+
+ /**
+ * The row a preview link points at.
+ *
+ * Normally the revision's frozen snapshot, so a reviewer sees what the editor
+ * was looking at when they shared the link rather than whatever the record
+ * has drifted to since.
+ *
+ * `r === 0` is the one case that reads live: a record that predates its
+ * content type opting into `editorial` has no revision to freeze. It is still
+ * scoped to the id inside the signed token, and still projected through the
+ * public allowlist - only the "frozen" guarantee is unavailable, because
+ * there is nothing to freeze.
+ */
+ const readPreviewRow = async (
+ c: Context,
+ payload: ContentPreviewTokenPayload,
+ ): Promise> => {
+ if (payload.r > 0) {
+ const build = model.editorialService;
+ if (!build) return null;
+
+ // Scoped by the record id from the token as well as the revision id: the
+ // revisions table is shared, so an id alone proves nothing about
+ // ownership - and the token's own id is the one this route trusts.
+ const revision = await build(c, { pluginId }).revisions.findById(
+ payload.i,
+ payload.r,
+ );
+
+ return revision ? contentSnapshotRow(revision.snapshot) : null;
+ }
+
+ // Deliberately no published predicate - previewing a draft is the whole
+ // feature - but still only the allowlisted columns, so a private one is
+ // never fetched in the first place.
+ const [row] = await c
+ .get("db")
+ .select(contentPublicSelection(definition, model.columns))
+ .from(table)
+ .where(eq(model.columns.id, payload.i))
+ .limit(1);
+
+ return row ?? null;
+ };
+
const list = buildRoute({
pluginId,
route: {
@@ -125,6 +189,74 @@ export const buildContentPublicRoutes = <
},
});
+ /**
+ * The one public route that can return an unpublished record.
+ *
+ * Everything that makes that safe is in this handler, so it is worth reading
+ * as a whole:
+ *
+ * - **The token is the authorization.** Signed with HMAC-SHA256, bound to one
+ * plugin, one content type, one record and one revision, and expiring. No
+ * session is consulted, which is the point - a reviewer has no account.
+ * - **Every failure is the same 404.** A forged signature, an expired link, a
+ * token for another record and a record that never existed are
+ * indistinguishable. A 401 or a 403 would confirm the record exists, which
+ * is precisely what a draft URL must not do.
+ * - **The projection is the public one.** `createContentPublicProjector` is
+ * the same function the detail route uses, so a private field cannot be
+ * public here and private there.
+ * - **Nothing caches it.** `private, no-store` keeps it out of shared caches
+ * and `noindex, nofollow` keeps it out of search results, in case a link is
+ * pasted somewhere public.
+ */
+ const preview = buildRoute({
+ pluginId,
+ route: {
+ method: "get",
+ // Two segments, so it can never shadow `/{slug}` - a record whose slug is
+ // literally "preview" still resolves the ordinary way.
+ path: "/preview/{token}",
+ description: `Read one ${label.singular} from a signed preview link`,
+ request: { params: z.object({ token: z.string() }) },
+ responses: {
+ 200: {
+ content: {
+ "application/json": { schema: schemas.publicSelectObject },
+ },
+ description: `${label.singular} as the link's revision recorded it`,
+ },
+ 404: { description: "No such preview" },
+ },
+ },
+ handler: async c => {
+ const secret =
+ c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret;
+
+ // Fail closed, and fail *indistinguishably*. A deployment whose secret is
+ // missing or still the published placeholder can have its tokens forged
+ // by anyone, so no token is honoured at all - and the answer is the same
+ // 404 a bad signature gets, because "preview is misconfigured here" is
+ // not something an anonymous request needs to learn.
+ if (!isSecureContentPreviewSecret(secret)) throw notFound();
+
+ const payload = verifyContentPreviewToken({
+ definition,
+ pluginId,
+ secret,
+ token: c.req.param("token"),
+ });
+ if (!payload) throw notFound();
+
+ const row = await readPreviewRow(c, payload);
+ if (!row) throw notFound();
+
+ return c.json(project(row), 200, {
+ "Cache-Control": "private, no-store",
+ "X-Robots-Tag": "noindex, nofollow",
+ });
+ },
+ });
+
const detail = buildRoute({
pluginId,
route: {
@@ -153,5 +285,11 @@ export const buildContentPublicRoutes = <
},
});
- return [list, detail];
+ return [
+ list,
+ // Before `detail` for readability only - the two can never both match, so
+ // the order carries no meaning.
+ ...(definition.editorial.preview.enabled ? [preview] : []),
+ detail,
+ ];
};
diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts
index ca7f602e9..ab9b4cf15 100644
--- a/packages/vitnode/src/content/server/public-service.ts
+++ b/packages/vitnode/src/content/server/public-service.ts
@@ -62,6 +62,77 @@ export interface ContentPublicService {
}>;
}
+/**
+ * The public projection, as a standalone function.
+ *
+ * Extracted so the preview route can use **this** rather than a second
+ * implementation that looks the same on the day it is written. The allowlist,
+ * the relation-to-`{ id }` collapse and the "drop the cursor `id` unless it was
+ * exposed" rule are one piece of code, so a field cannot become public on one
+ * route and stay private on the other.
+ *
+ * It reads nothing but the definition: no database handle, no columns, no
+ * joins. An exposed relation is projected from the foreign key the row already
+ * carries, which is what makes it impossible for one content type's allowlist
+ * to publish another's administrative metadata.
+ */
+export const createContentPublicProjector = <
+ TDefinition extends AnyContentTypeDefinition,
+>(
+ definition: TDefinition,
+): ((row: Record) => ContentPublicSelect) => {
+ const publicApi = definition.publicApi;
+
+ if (!publicApi.enabled) {
+ throw new ContentEngineError(
+ "This content type has no public API, so there is no public projection to build.",
+ { contentTypeId: definition.id },
+ );
+ }
+
+ const exposed = publicApi.fields;
+ const exposesId = exposed.includes("id");
+ // A `user` field is never exposable, so this is only ever relations.
+ const exposedRelations = new Set(
+ exposed.filter(name => definition.fields[name]?.kind === "relation"),
+ );
+
+ return row => {
+ const projected: Record = {};
+
+ for (const name of exposed) {
+ if (!exposedRelations.has(name)) {
+ projected[name] = row[name];
+ continue;
+ }
+
+ const id = row[name];
+ projected[name] = typeof id === "number" ? { id } : null;
+ }
+
+ if (exposesId) projected.id = row.id;
+
+ return projected as ContentPublicSelect;
+ };
+};
+
+/**
+ * The columns a public read selects: the allowlist, plus `id` for the cursor.
+ *
+ * `id` is fetched whether or not it is exposed, because pagination needs it -
+ * and then dropped again by the projector. A private column is never in this
+ * map at all, so it cannot leak through a mistake further downstream.
+ */
+export const contentPublicSelection = (
+ definition: AnyContentTypeDefinition,
+ columns: Record,
+): Record => ({
+ id: columns.id,
+ ...Object.fromEntries(
+ definition.publicApi.fields.map(name => [name, columns[name]]),
+ ),
+});
+
/** Public pages are smaller than admin ones, and the cap is lower too. */
const clampPageSize = (value: string | undefined): string | undefined => {
if (value === undefined) return undefined;
@@ -121,47 +192,13 @@ export const createContentPublicService = <
const primaryCursor = columns.id as PgColumn<
ColumnBaseConfig<"number", string>
>;
- const exposed = publicApi.fields;
- const exposesId = exposed.includes("id");
- // A `user` field is never exposable, so this is only ever relations.
- const exposedRelations = new Set(
- exposed.filter(name => fields[name]?.kind === "relation"),
- );
const searchColumns = publicApi.searchableFields.map(name => columns[name]);
const orderable = publicOrderableColumns(definition);
- /** Own columns, plus `id` for the cursor whether or not it is exposed. */
- const selection = (): Record => ({
- id: primaryCursor,
- ...Object.fromEntries(exposed.map(name => [name, columns[name]])),
- });
-
- /**
- * Turns one raw row into the public projection: relations collapse to
- * `{ id }`, and the cursor `id` disappears unless the allowlist asked for it.
- *
- * The relation identifier is the foreign key already on this row, so no
- * target table is read and no label is invented.
- */
- const project = (
- row: Record,
- ): ContentPublicSelect => {
- const projected: Record