From f88b13cc45de4d5d062e95728116a66afde763c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:02:56 +0000 Subject: [PATCH 01/25] =?UTF-8?q?feat(ws):=20=E2=9C=A8=20add=20rooms=20to?= =?UTF-8?q?=20the=20WebSocket=20registry=20and=20the=20content=20live=20pr?= =?UTF-8?q?otocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms count members per browser tab, since tabs share one socket, and fan out across instances over the existing Redis pub/sub channel. The content live protocol is the shared contract for presence, field locks, drafts and collaborative rich text documents. Adds Yjs and the Tiptap collaboration packages, and the plan in plans/content-live-editing.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- packages/vitnode/package.json | 7 +- packages/vitnode/src/content/live/protocol.ts | 197 +++++++++++++++ packages/vitnode/src/ws/registry.ts | 127 +++++++++- packages/vitnode/src/ws/rooms.test.ts | 130 ++++++++++ plans/content-live-editing.md | 227 ++++++++++++++++++ 5 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/content/live/protocol.ts create mode 100644 packages/vitnode/src/ws/rooms.test.ts create mode 100644 plans/content-live-editing.md diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 4fbb59b229..1429523a86 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -233,6 +233,8 @@ "@simplewebauthn/server": "^14.0.3", "@tanstack/charts": "^1.0.0", "@tiptap/extension-audio": "^3.31.4", + "@tiptap/extension-collaboration": "^3.31.4", + "@tiptap/extension-collaboration-caret": "^3.31.4", "@tiptap/extension-drag-handle": "^3.31.4", "@tiptap/extension-drag-handle-react": "^3.31.4", "@tiptap/extension-emoji": "^3.31.4", @@ -246,6 +248,7 @@ "@tiptap/react": "^3.31.4", "@tiptap/starter-kit": "^3.31.4", "@tiptap/suggestion": "^3.31.4", + "@tiptap/y-tiptap": "^3.0.9", "ai": "^7.0.134", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", @@ -271,7 +274,9 @@ "shadcn": "^4.21.4", "sharp": "^0.35.5", "use-debounce": "^10.1.1", - "use-intl": "^4.14.9" + "use-intl": "^4.14.9", + "y-protocols": "^1.0.7", + "yjs": "^13.6.33" }, "peerDependenciesMeta": { "@tanstack/react-query": { diff --git a/packages/vitnode/src/content/live/protocol.ts b/packages/vitnode/src/content/live/protocol.ts new file mode 100644 index 0000000000..6908356603 --- /dev/null +++ b/packages/vitnode/src/content/live/protocol.ts @@ -0,0 +1,197 @@ +import { createWebSocketChannel } from "@/ws/types"; + +/** + * The wire contract of Content Engine live editing: presence, field locks, + * the shared draft and the collaborative rich text documents. One WebSocket + * channel carries all of it; every message names its record room. + * + * A browser shares one socket across its tabs, so every client message carries + * the `clientId` of the tab that sent it, and every tab filters what it + * receives by room and document. + */ + +/** The fields that identify one record of one content type. */ +export interface ContentLiveRoomRef { + contentTypeId: string; + itemId: number; +} + +/** One collaborative rich text document: a field of a record in a language. */ +export interface ContentLiveDocRef extends ContentLiveRoomRef { + field: string; + /** `null` for a field that is not localized. */ + locale: null | string; +} + +export interface ContentLiveMember { + avatarColor: null | string; + /** The tab: one person in two tabs is two members. */ + clientId: string; + /** The field the member is in, or `null` when no field has focus. */ + field: null | string; + /** The language the member is editing, `null` for a non-localized form. */ + locale: null | string; + name: string; + nameCode: null | string; + userId: number; +} + +export interface ContentFieldLock { + expiresAt: string; + field: string; + /** `null` for a shared (non-localized) field. */ + locale: null | string; + user: { id: number; name: string }; +} + +export type ContentLiveClientMessage = + | { + clientId: string; + doc: ContentLiveDocRef; + stateVector: string; + type: "doc:open"; + } + | { + clientId: string; + doc: ContentLiveDocRef; + type: "doc:awareness" | "doc:update"; + /** A base64 Yjs update, or a base64 awareness update. */ + update: string; + } + | { + clientId: string; + doc: ContentLiveDocRef; + type: "doc:close"; + } + | { + clientId: string; + field: null | string; + locale: null | string; + room: ContentLiveRoomRef; + type: "focus"; + } + | { + clientId: string; + locale: null | string; + room: ContentLiveRoomRef; + type: "join"; + } + | { + clientId: string; + room: ContentLiveRoomRef; + type: "heartbeat" | "leave"; + }; + +export type ContentLiveErrorCode = + "FORBIDDEN" | "INVALID_MESSAGE" | "NOT_FOUND" | "NOT_JOINED"; + +export type ContentLiveServerMessage = + | { + /** The person whose write changed the draft. */ + by: { id: number; name: string }; + locale: null | string; + room: ContentLiveRoomRef; + type: "draft"; + updatedAt: string; + values: Record; + } + | { + /** The tab that must seed the empty document from the record's JSON. */ + clientId: string; + doc: ContentLiveDocRef; + type: "doc:seed"; + } + | { + /** Why every member must reload the record, its draft and documents. */ + reason: "deleted" | "restored"; + room: ContentLiveRoomRef; + type: "reset"; + } + | { + clientId: string; + code: ContentLiveErrorCode; + room?: ContentLiveRoomRef; + type: "error"; + } + | { + clientId: string; + doc: ContentLiveDocRef; + stateVector: string; + type: "doc:state-vector"; + } + | { + clientId: string; + members: ContentLiveMember[]; + room: ContentLiveRoomRef; + type: "joined"; + } + | { + doc: ContentLiveDocRef; + /** + * The member that sent the update, so its own tab can ignore the echo + * (applying it again would be harmless anyway). + */ + from: null | string; + type: "doc:awareness" | "doc:update"; + update: string; + } + | { + locks: ContentFieldLock[]; + room: ContentLiveRoomRef; + type: "locks"; + } + | { + members: ContentLiveMember[]; + room: ContentLiveRoomRef; + type: "presence"; + }; + +export const contentLiveChannel = createWebSocketChannel< + ContentLiveClientMessage, + ContentLiveServerMessage +>({ pluginId: "@vitnode/core", module: "content", id: "live" }); + +/** The registry room of one record. */ +export const contentLiveRoom = ({ + contentTypeId, + itemId, +}: ContentLiveRoomRef): string => `content:${contentTypeId}:${itemId}`; + +/** The registry room of one collaborative document. */ +export const contentLiveDocRoom = ({ + contentTypeId, + field, + itemId, + locale, +}: ContentLiveDocRef): string => + `content-doc:${contentTypeId}:${itemId}:${field}:${locale ?? "_"}`; + +export const sameContentLiveRoom = ( + a: ContentLiveRoomRef, + b: ContentLiveRoomRef, +): boolean => a.contentTypeId === b.contentTypeId && a.itemId === b.itemId; + +export const sameContentLiveDoc = ( + a: ContentLiveDocRef, + b: ContentLiveDocRef, +): boolean => + sameContentLiveRoom(a, b) && a.field === b.field && a.locale === b.locale; + +/** A field lock lives this long unless its holder renews it. */ +export const CONTENT_FIELD_LOCK_LEASE_MS = 60_000; +/** How often a holder renews a lock while the field has focus. */ +export const CONTENT_FIELD_LOCK_RENEW_MS = 20_000; +/** How often a tab tells the room it is still there. */ +export const CONTENT_LIVE_HEARTBEAT_MS = 15_000; +/** A member that missed heartbeats for this long is gone. */ +export const CONTENT_LIVE_MEMBER_TIMEOUT_MS = 45_000; +/** How long the client waits after the last keystroke before autosaving. */ +export const CONTENT_DRAFT_AUTOSAVE_MS = 1_500; +/** How often a tab without a socket polls locks and the draft. */ +export const CONTENT_LIVE_POLL_MS = 10_000; +/** How long the server waits after the last update before persisting a document. */ +export const CONTENT_DOCUMENT_PERSIST_MS = 2_000; +/** Drafts and documents behind the record and older than this are removed. */ +export const CONTENT_LIVE_STALE_DAYS = 30; +/** The `language` column value of a shared (non-localized) lock or draft. */ +export const CONTENT_LIVE_SHARED_LOCALE = ""; diff --git a/packages/vitnode/src/ws/registry.ts b/packages/vitnode/src/ws/registry.ts index 73e402878e..5c9417d2e1 100644 --- a/packages/vitnode/src/ws/registry.ts +++ b/packages/vitnode/src/ws/registry.ts @@ -6,6 +6,20 @@ import type { VitNodeWSChannel, VitNodeWSMessage } from "./types"; const connections = new Map(); +/** + * Room name -> socket -> the members (one per browser tab) that joined through + * that socket. A browser shares one socket across its tabs, so a socket stays + * in a room while at least one of its tabs is. + */ +const rooms = new Map>>(); + +type ConnectionCloseListener = ( + ws: WSContext, + left: { memberId: string; room: string }[], +) => void; + +const closeListeners = new Set(); + let webSocketEnabled = false; /** Called once when the app mounts the `/ws` handler. */ @@ -32,8 +46,80 @@ export const wsRegistry = { broadcast: (id: string, data: unknown): void => { connections.forEach((_userId, ws) => sendTo(ws, id, data)); }, + /** + * Add one member (a browser tab) of `ws` to `room`. Idempotent. Returns `true` + * when the member was not in the room yet. + */ + join: (ws: WSContext, room: string, memberId: string): boolean => { + const sockets = rooms.get(room) ?? new Map>(); + rooms.set(room, sockets); + const members = sockets.get(ws) ?? new Set(); + sockets.set(ws, members); + + if (members.has(memberId)) return false; + members.add(memberId); + + return true; + }, + /** + * Remove one member of `ws` from `room`. Returns `true` when it was there. + */ + leave: (ws: WSContext, room: string, memberId: string): boolean => { + const sockets = rooms.get(room); + const members = sockets?.get(ws); + if (!sockets || !members?.delete(memberId)) return false; + + if (members.size === 0) sockets.delete(ws); + if (sockets.size === 0) rooms.delete(room); + + return true; + }, + /** Every member of `room` on this instance, with the socket it uses. */ + membersOf: (room: string): { memberId: string; ws: WSContext }[] => + [...(rooms.get(room) ?? new Map>())].flatMap( + ([ws, members]) => [...members].map(memberId => ({ memberId, ws })), + ), + /** Run `listener` whenever a socket closes, with the rooms it was in. */ + onConnectionClose: (listener: ConnectionCloseListener): (() => void) => { + closeListeners.add(listener); + + return () => { + closeListeners.delete(listener); + }; + }, remove: (ws: WSContext): void => { connections.delete(ws); + + const left: { memberId: string; room: string }[] = []; + rooms.forEach((sockets, room) => { + const members = sockets.get(ws); + if (!members) return; + + members.forEach(memberId => left.push({ memberId, room })); + sockets.delete(ws); + if (sockets.size === 0) rooms.delete(room); + }); + + if (left.length === 0) return; + + closeListeners.forEach(listener => { + try { + listener(ws, left); + } catch (error) { + // eslint-disable-next-line no-console + console.error("WebSocket close listener error:", error); + } + }); + }, + /** Every room `ws` has at least one member in. */ + roomsOf: (ws: WSContext): string[] => + [...rooms].filter(([, sockets]) => sockets.has(ws)).map(([room]) => room), + /** + * Send `{ id, data }` once to every socket with a member in `room`. Tabs that + * share the socket filter the payload themselves. + */ + toRoom: (room: string, id: string, data: unknown): void => { + rooms.get(room)?.forEach((_members, ws) => sendTo(ws, id, data)); }, /** Send `{ id, data }` only to the connections owned by `userId`. */ sendToUser: (userId: number, id: string, data: unknown): void => { @@ -63,7 +149,8 @@ interface RealtimePubSubMessage { data: unknown; id: string; origin: string; - type: "broadcast" | "sendToUser"; + room?: string; + type: "broadcast" | "room" | "sendToUser"; userId?: number; } @@ -75,9 +162,30 @@ const deliverLocally = (message: RealtimePubSubMessage): void => { typeof message.userId === "number" ) { wsRegistry.sendToUser(message.userId, message.id, message.data); + } else if (message.type === "room" && typeof message.room === "string") { + wsRegistry.toRoom(message.room, message.id, message.data); } }; +const roomListeners = new Set< + (room: string, id: string, data: unknown) => void +>(); + +/** + * Observe room messages published by other instances, before they are + * delivered to local members. Features that keep per-room state in memory (a + * collaborative document) use it to stay in step with the other instances. + */ +export const onRemoteRoomMessage = ( + listener: (room: string, id: string, data: unknown) => void, +): (() => void) => { + roomListeners.add(listener); + + return () => { + roomListeners.delete(listener); + }; +}; + const publish = (message: Omit): void => { if (!publisher) return; @@ -115,6 +223,10 @@ export const initRealtimePubSub = (client: CacheClient | null): void => { const message = JSON.parse(raw) as RealtimePubSubMessage; // Skip our own echo - the publisher already delivered locally. if (message.origin === instanceId) return; + if (message.type === "room" && typeof message.room === "string") { + const { data, id, room } = message; + roomListeners.forEach(listener => listener(room, id, data)); + } deliverLocally(message); } catch { // Ignore malformed payloads. @@ -140,6 +252,15 @@ export interface VitNodeRealtime { channel: VitNodeWSChannel, data: Receive, ) => void; + /** + * Push a payload to every member of a room, on every instance. Members join + * with `wsRegistry.join`; a room is any string a feature chooses. + */ + toRoom: ( + room: string, + channel: VitNodeWSChannel, + data: Receive, + ) => void; } export const realtime: VitNodeRealtime = { @@ -151,4 +272,8 @@ export const realtime: VitNodeRealtime = { wsRegistry.sendToUser(userId, channel.id, data); publish({ data, id: channel.id, type: "sendToUser", userId }); }, + toRoom: (room, channel, data) => { + wsRegistry.toRoom(room, channel.id, data); + publish({ data, id: channel.id, room, type: "room" }); + }, }; diff --git a/packages/vitnode/src/ws/rooms.test.ts b/packages/vitnode/src/ws/rooms.test.ts new file mode 100644 index 0000000000..8117fe6415 --- /dev/null +++ b/packages/vitnode/src/ws/rooms.test.ts @@ -0,0 +1,130 @@ +import type { WSContext } from "hono/ws"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { realtime, wsRegistry } from "./registry"; +import { createWebSocketChannel } from "./types"; + +const sockets: WSContext[] = []; + +const socket = (userId: null | number = 1) => { + const sent: { data: unknown; id: string }[] = []; + const ws = { + send: (raw: string) => { + sent.push(JSON.parse(raw) as { data: unknown; id: string }); + }, + } as unknown as WSContext; + + wsRegistry.add(ws, userId); + sockets.push(ws); + + return { sent, ws }; +}; + +afterEach(() => { + sockets.splice(0).forEach(ws => wsRegistry.remove(ws)); +}); + +describe("rooms", () => { + it("delivers a room message only to sockets with a member in the room", () => { + const inside = socket(); + const outside = socket(); + + wsRegistry.join(inside.ws, "content:blog.post:1", "tab-a"); + wsRegistry.toRoom("content:blog.post:1", "live", { hello: true }); + + expect(inside.sent).toEqual([{ data: { hello: true }, id: "live" }]); + expect(outside.sent).toEqual([]); + }); + + it("sends once per socket, however many of its tabs joined", () => { + const shared = socket(); + + wsRegistry.join(shared.ws, "room", "tab-a"); + wsRegistry.join(shared.ws, "room", "tab-b"); + wsRegistry.toRoom("room", "live", 1); + + expect(shared.sent).toHaveLength(1); + }); + + it("keeps the socket in the room until its last tab leaves", () => { + const shared = socket(); + + wsRegistry.join(shared.ws, "room", "tab-a"); + wsRegistry.join(shared.ws, "room", "tab-b"); + wsRegistry.leave(shared.ws, "room", "tab-a"); + + expect(wsRegistry.roomsOf(shared.ws)).toEqual(["room"]); + + wsRegistry.leave(shared.ws, "room", "tab-b"); + + expect(wsRegistry.roomsOf(shared.ws)).toEqual([]); + expect(wsRegistry.membersOf("room")).toEqual([]); + }); + + it("reports whether a join or a leave changed anything", () => { + const { ws } = socket(); + + expect(wsRegistry.join(ws, "room", "tab-a")).toBe(true); + expect(wsRegistry.join(ws, "room", "tab-a")).toBe(false); + expect(wsRegistry.leave(ws, "room", "tab-a")).toBe(true); + expect(wsRegistry.leave(ws, "room", "tab-a")).toBe(false); + }); + + it("lists every member with its socket", () => { + const first = socket(); + const second = socket(2); + + wsRegistry.join(first.ws, "room", "tab-a"); + wsRegistry.join(second.ws, "room", "tab-b"); + + expect(wsRegistry.membersOf("room")).toEqual([ + { memberId: "tab-a", ws: first.ws }, + { memberId: "tab-b", ws: second.ws }, + ]); + }); + + it("tells close listeners which members left with a closed socket", () => { + const { ws } = socket(); + const listener = vi.fn(); + const unsubscribe = wsRegistry.onConnectionClose(listener); + + wsRegistry.join(ws, "first", "tab-a"); + wsRegistry.join(ws, "second", "tab-b"); + wsRegistry.remove(ws); + unsubscribe(); + + expect(listener).toHaveBeenCalledWith(ws, [ + { memberId: "tab-a", room: "first" }, + { memberId: "tab-b", room: "second" }, + ]); + expect(wsRegistry.membersOf("first")).toEqual([]); + }); + + it("does not call close listeners for a socket that was in no room", () => { + const { ws } = socket(); + const listener = vi.fn(); + const unsubscribe = wsRegistry.onConnectionClose(listener); + + wsRegistry.remove(ws); + unsubscribe(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("realtime.toRoom", () => { + it("delivers to the room's local members by channel id", () => { + const channel = createWebSocketChannel({ + id: "live", + module: "content", + pluginId: "@vitnode/core", + }); + const member = socket(); + + wsRegistry.join(member.ws, "room", "tab-a"); + realtime.toRoom("room", channel, { n: 1 }); + + expect(member.sent).toEqual([{ data: { n: 1 }, id: channel.id }]); + }); +}); diff --git a/plans/content-live-editing.md b/plans/content-live-editing.md new file mode 100644 index 0000000000..6fbdb26473 --- /dev/null +++ b/plans/content-live-editing.md @@ -0,0 +1,227 @@ +# Content Engine: live editing, autosave and presence + +Status: in progress (canary). Owner: Content Engine. + +## Goals + +1. **Autosave.** Nobody loses work. Edits are saved continuously to a shared + working copy (the _draft_), never to the published record. `Save` and + `Publish` stay explicit commits. +2. **Presence.** Editors see who else is in the record and _where_ they are: + which field and which language. +3. **Hard field locks.** A plain field (title, slug, excerpt, relations, files, + ...) is edited by one person at a time. Everyone else sees it read-only with + "Anna is editing", and gets the value live. +4. **Co-editing in the rich text editor.** The rich text field is the exception: + many people type in it at once, with live carets (Yjs). +5. **JSON content + SSR without the editor.** Rich text is stored as ProseMirror + JSON and rendered on the server by a pure renderer. The public bundle never + ships Tiptap, ProseMirror or Yjs. + +Non-goals: offline-first editing, comments and suggestions (see "Later"). + +## Constraints found in the code + +- Every editorial `PUT` bumps `version`, writes a revision (retention 20) and + runs the commit effects (events, search indexing, revalidation). Autosave + must not go through it. +- A draft is not a separate copy: `status = "draft"` lives on the same row, so + autosaving a published record would change live content. Autosave writes to + separate tables instead. +- The WebSocket layer (`packages/vitnode/src/ws`) knows users, not rooms. The + socket is only mounted by `apps/api` (long-lived Node). Vercel/serverless + has no socket. +- The socket only knows the _user_ session. The AdminCP session cookie + (`path=/`) does reach the upgrade request, so a handler can resolve it with + `SessionAdminModel` and check `can_edit` itself. +- The editor (Tiptap v3) stores HTML today and is uncontrolled after mount. + +## Decisions + +| Topic | Decision | +| --- | --- | +| Where autosave goes | `core_content_drafts` (plain fields) and `core_content_documents` (Yjs state of rich text fields). Never the record. | +| Draft scope | One shared draft per record and language, not per user. It is the team's working copy. | +| Plain fields | Hard lock per field and language, a lease in `core_content_field_locks`. | +| Rich text | `field.richText()`, ProseMirror JSON in `jsonb`, co-edited through Yjs. | +| Transport | Existing `/api/ws` socket, one core channel, rooms on top of the registry. Redis pub/sub for multi-instance. | +| No socket (serverless) | Locks and drafts work over HTTP with polling. Rich text falls back to a single-editor lock. | +| Migration | Canary: none. `blog.post.content` changes from `textarea` to `richText` (breaking). A one-time HTML to JSON script is written before the stable release. | +| New packages | `yjs`, `y-protocols`, `@tiptap/extension-collaboration`, `@tiptap/extension-collaboration-caret`, `@tiptap/y-tiptap` (core only). | + +## Architecture + +``` +AdminCP form ──HTTP──▶ /content/{module}/{id}/locks (acquire, renew, release) + │ /content/{module}/{id}/draft (read, write locked fields) + │ PUT /{id}, /{id}/localized (commit = Save, unchanged) + │ + └──WS /api/ws──▶ channel "@vitnode/core_content_live" + join/leave/focus ─▶ presence of the record room + doc:* (Yjs sync) ─▶ rich text documents + ◀─ presence, locks, draft, doc:update, doc:awareness, reset + │ + Redis "vitnode:ws" (room fan-out across instances) +``` + +### Rooms (WebSocket layer) + +`ws/registry.ts` gains rooms: + +- `wsRegistry.join(ws, room)`, `wsRegistry.leave(ws, room)`, + `wsRegistry.roomsOf(ws)`, `wsRegistry.toRoom(room, id, data, { except })`. +- `realtime.toRoom(room, channel, data)` delivers locally and publishes a + `room` message on `vitnode:ws`, so other instances deliver to their members. +- `onConnectionClose(listener)` lets features clean up (presence, locks) when a + socket goes away. + +Room names: `content:{contentTypeId}:{itemId}` for a record and +`content-doc:{contentTypeId}:{itemId}:{field}:{locale|_}` for one rich text +document. + +### Protocol (`content/live/protocol.ts`) + +One channel, `createWebSocketChannel({ pluginId: "@vitnode/core", module: "content", id: "live" })`. +JSON messages, binary Yjs payloads as base64. + +Client to server: + +| type | payload | effect | +| --- | --- | --- | +| `join` | `room`, `locale` | Authorize (AdminCP session + `can_edit`), join the room, reply `joined` with members. | +| `leave` | `room` | Leave the room and every document room of it. | +| `focus` | `room`, `field`, `locale` | Update presence ("Anna is in Title (PL)"). | +| `doc:open` | `doc`, `stateVector` | Join the document room. Server replies with `doc:update` (what the client misses) and `doc:state-vector`. | +| `doc:update` | `doc`, `update` | Apply on the server doc, relay to the document room, schedule persistence. | +| `doc:awareness` | `doc`, `update` | Relay carets and selections. | +| `doc:close` | `doc` | Leave the document room. | + +Server to client: `joined`, `presence`, `locks`, `draft`, `doc:update`, +`doc:state-vector`, `doc:awareness`, `doc:seed`, `reset`, `error`. + +### Presence + +Per record room, the server keeps `{ connectionId, userId, name, avatar, +locale, field }` for each member and broadcasts the list on every change. A +member leaves on `leave`, on socket close, or after a missed heartbeat. + +### Field locks (`core_content_field_locks`) + +- Key: `(content_type_id, item_id, field, language)`. `language` is `""` for + shared fields. Value: `user_id`, `acquired_at`, `expires_at`. +- `POST /{id}/locks` with `{ field, locale, action: "acquire" | "renew" | "release" }`. + Acquire is one atomic upsert that only succeeds when the lock is free, + expired, or already held by the caller. A conflict is a `409` naming the + holder. +- Lease 60 s, renewed every 20 s while the field has focus. +- `GET /{id}/locks` lists live locks (also the polling fallback). +- Every change is broadcast to the record room as `locks`. +- A socket close releases that user's locks in the room when they have no + other connection in it. +- `can_edit` is required for everything. Rich text fields are never locked + while the socket is up. + +### Drafts (`core_content_drafts`) + +- Key: `(content_type_id, item_id, language)`. `values` is `jsonb`. + `base_version`, `updated_at`, `updated_by`. +- `GET /{id}/draft` returns the shared and per-language drafts. +- `PUT /{id}/draft` with `{ locale, values }` merges only fields the caller + holds a lock for (otherwise `409`). The result is broadcast as `draft`. +- The form opens with record values overlaid by draft values, and shows + "Draft saved at 14:32" and "Unsaved changes" (draft differs from record). +- `Save` commits the overlaid values through the existing routes. The draft + stays; it now equals the record. Restoring a revision deletes the drafts and + documents of that record and broadcasts `reset`. +- The editorial cleanup cron deletes drafts and documents older than 30 days + whose `base_version` is behind the record. + +### Rich text (`field.richText()`) + +- New field kind `richText`, `localized` allowed. Column `jsonb`. Value is a + ProseMirror document `{ type: "doc", content: [...] }`. +- Validation: recursive Zod schema with a node allowlist, max depth and max + serialized size. +- `RichTextContent` (`@vitnode/core/components/rich-text`) renders the JSON to + React on the server and the client. A registry maps node and mark types to + renderers; unknown nodes render their children. Links are sanitized. +- `richTextToPlainText()` feeds search, SEO descriptions, excerpts and + revision diffs. `richTextToHtml()` is a string renderer for AI prompts and + emails. +- The admin editor works in JSON mode: `onChange(editor.getJSON())`. + +### Co-editing (Yjs) + +- One `Y.Doc` per `(record, field, language)`. The Tiptap `Collaboration` + extension binds the editor to it, `CollaborationCaret` shows carets with the + member's name and color. Undo and redo come from Yjs (per user). +- Client provider (`content/live/provider.ts`) speaks the protocol above over + the shared socket and owns an `Awareness`. +- Server (`content/server/live/documents.ts`) keeps loaded documents in memory + per instance, applies and relays updates, mirrors them to other instances + over Redis, and persists the merged state to `core_content_documents` + (debounced 2 s, and when the last member leaves). +- Seeding: when no state is stored, the server asks the first client to seed + the document from the record JSON (`doc:seed`). Only one client is chosen. +- `Save` sends the editor JSON in the normal payload. The server never has to + turn Yjs into JSON. +- Without the socket, the editor is a normal JSON editor guarded by the field + lock. + +### AdminCP and the blog + +- `ContentForm` gets a live session (`useContentLiveSession`) when the record + exists: join, presence, locks, draft overlay, autosave indicator. +- Locked fields render read-only with the holder's avatar. Focus acquires the + lock; blur releases it. +- The page header shows the avatars of everyone in the record. The language + switcher shows who is in each language. +- The blog switches `content` to `field.richText({ localized: true, required: true })`, + renders it with `RichTextContent`, and its AI tools (excerpt, translate) + read text with `richTextToPlainText` / `richTextToHtml` and write back + through editor commands, so changes flow through Yjs to everyone. + +## Phases + +| Phase | Scope | Notes | +| --- | --- | --- | +| 0 | Rooms in the WS registry, protocol contract, shared constants | Foundation for everything below. | +| 1 | `field.richText`, renderer, plain text and HTML helpers, editor JSON mode, blog switch | Independent. | +| 2 | Live server: WS handler, auth, presence, Yjs document sync and persistence | Builds on 0. | +| 3 | Locks and drafts: tables, routes, broadcasts, cleanup | Builds on 0. | +| 4 | AdminCP client: live session, presence UI, lock UI, autosave, collaborative editor, blog editor | Builds on 1-3. | +| 5 | Docs and migration files | Last. | + +Phases 1, 2 and 3 run in parallel. + +## Testing + +Vitest, following `AGENTS.md`: no `vi.mock` where a seam exists. + +- Rooms: join, leave, `toRoom` with `except`, cleanup on close, pub/sub + delivery to room members only. +- Live handler: rejects without an AdminCP session or without `can_edit`; + presence lists; focus updates. +- Documents: two clients converge; updates relay to the document room only; + persistence and reload; seeding picks one client. +- Locks: acquire, conflict `409`, renew, expiry, release, release on close. +- Drafts: merge only locked fields, reject unlocked, read overlay, reset on + restore. +- Rich text: schema accepts valid docs and rejects bad ones; renderer DOM + output for each node and mark; link sanitizing; plain text extraction. +- UI: locked field is read-only and names the holder; presence avatars. + +## Docs + +- `dev/content-engine/live-editing.mdx`: autosave, presence, locks, co-editing, + deployment notes (socket and Redis). +- `dev/content-engine/fields.mdx`: `field.richText`. +- `dev/content-engine/rich-text.mdx`: rendering with `RichTextContent`, + custom node renderers, helpers. +- `dev/websocket.mdx`: rooms. +- `guides/blog.mdx`: what editors see. + +## Later + +Anchored comments, suggestion mode, AI as a visible collaborator, version +history from Yjs snapshots, co-editing the visual page editor. From 6ca303ee9b740f5ea047581fa1a533f4591d530b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:04:36 +0000 Subject: [PATCH 02/25] =?UTF-8?q?feat(content):=20=E2=9C=A8=20add=20live?= =?UTF-8?q?=20editing=20broadcast=20and=20lifecycle=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../src/content/server/live/broadcast.ts | 18 ++++ .../vitnode/src/content/server/live/hooks.ts | 82 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/vitnode/src/content/server/live/broadcast.ts create mode 100644 packages/vitnode/src/content/server/live/hooks.ts diff --git a/packages/vitnode/src/content/server/live/broadcast.ts b/packages/vitnode/src/content/server/live/broadcast.ts new file mode 100644 index 0000000000..f89d208a54 --- /dev/null +++ b/packages/vitnode/src/content/server/live/broadcast.ts @@ -0,0 +1,18 @@ +import type { + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { contentLiveChannel, contentLiveRoom } from "@/content/live/protocol"; +import { realtime } from "@/ws/registry"; + +/** + * Push a live message to everyone in one record's room, on every instance. A + * no-op for a room nobody joined, and for an app without a socket. + */ +export const broadcastContentLive = ( + room: ContentLiveRoomRef, + message: ContentLiveServerMessage, +): void => { + realtime.toRoom(contentLiveRoom(room), contentLiveChannel, message); +}; diff --git a/packages/vitnode/src/content/server/live/hooks.ts b/packages/vitnode/src/content/server/live/hooks.ts new file mode 100644 index 0000000000..cc855b6ee9 --- /dev/null +++ b/packages/vitnode/src/content/server/live/hooks.ts @@ -0,0 +1,82 @@ +import type { Context } from "hono"; + +import type { ContentLiveRoomRef } from "@/content/live/protocol"; + +import { broadcastContentLive } from "./broadcast"; + +/** + * Lifecycle seams between the live socket and the HTTP side of live editing, + * so neither imports the other. Listeners register at module load. + */ + +type UserLeftListener = (args: { + c: Context; + room: ContentLiveRoomRef; + userId: number; +}) => Promise | void; + +type ResetListener = (args: { + c: Context; + reason: "deleted" | "restored"; + room: ContentLiveRoomRef; +}) => Promise | void; + +const userLeftListeners = new Set(); +const resetListeners = new Set(); + +/** Runs when a user has no member (tab) left in a record's room. */ +export const onContentLiveUserLeft = ( + listener: UserLeftListener, +): (() => void) => { + userLeftListeners.add(listener); + + return () => { + userLeftListeners.delete(listener); + }; +}; + +/** Runs when a record's working state must be thrown away. */ +export const onContentLiveReset = (listener: ResetListener): (() => void) => { + resetListeners.add(listener); + + return () => { + resetListeners.delete(listener); + }; +}; + +const runAll = async ( + listeners: Set<(args: T) => Promise | void>, + args: T, +): Promise => { + const results = await Promise.allSettled( + [...listeners].map(async listener => { + await listener(args); + }), + ); + + for (const result of results) { + if (result.status === "rejected") { + // eslint-disable-next-line no-console + console.error("Content live listener error:", result.reason); + } + } +}; + +export const contentLiveUserLeft = async ( + args: Parameters[0], +): Promise => { + await runAll(userLeftListeners, args); +}; + +/** + * Drop a record's drafts, locks and collaborative documents, then tell every + * member to reload. Called after a revision restore and after a delete. + */ +export const resetContentLiveRecord = async ( + c: Context, + room: ContentLiveRoomRef, + reason: "deleted" | "restored", +): Promise => { + await runAll(resetListeners, { c, reason, room }); + broadcastContentLive(room, { reason, room, type: "reset" }); +}; From 20f993fe8a59ae9d1db7d92a785986bf14b54ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:36:45 +0000 Subject: [PATCH 03/25] =?UTF-8?q?feat(content):=20=E2=9C=A8=20add=20live?= =?UTF-8?q?=20editing=20socket:=20presence=20and=20collaborative=20documen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- apps/web/content/docs/dev/websocket.mdx | 82 ++- .../src/api/lib/check-staff-permission.ts | 21 +- .../src/api/modules/content/content.module.ts | 2 + .../src/content/server/live/auth.test.ts | 297 +++++++++ .../vitnode/src/content/server/live/auth.ts | 179 ++++++ .../live/document-store.integration.test.ts | 163 +++++ .../src/content/server/live/document-store.ts | 139 +++++ .../src/content/server/live/documents.test.ts | 444 ++++++++++++++ .../src/content/server/live/documents.ts | 568 ++++++++++++++++++ .../src/content/server/live/messages.ts | 75 +++ .../src/content/server/live/presence.test.ts | 531 ++++++++++++++++ .../src/content/server/live/presence.ts | 259 ++++++++ .../vitnode/src/content/server/live/server.ts | 491 +++++++++++++++ .../vitnode/src/content/server/live/socket.ts | 30 + .../vitnode/src/database/content-documents.ts | 60 ++ packages/vitnode/src/tests/content-live.ts | 172 ++++++ packages/vitnode/src/ws/instances.test.ts | 162 +++++ packages/vitnode/src/ws/registry.ts | 46 +- 18 files changed, 3715 insertions(+), 6 deletions(-) create mode 100644 packages/vitnode/src/content/server/live/auth.test.ts create mode 100644 packages/vitnode/src/content/server/live/auth.ts create mode 100644 packages/vitnode/src/content/server/live/document-store.integration.test.ts create mode 100644 packages/vitnode/src/content/server/live/document-store.ts create mode 100644 packages/vitnode/src/content/server/live/documents.test.ts create mode 100644 packages/vitnode/src/content/server/live/documents.ts create mode 100644 packages/vitnode/src/content/server/live/messages.ts create mode 100644 packages/vitnode/src/content/server/live/presence.test.ts create mode 100644 packages/vitnode/src/content/server/live/presence.ts create mode 100644 packages/vitnode/src/content/server/live/server.ts create mode 100644 packages/vitnode/src/content/server/live/socket.ts create mode 100644 packages/vitnode/src/database/content-documents.ts create mode 100644 packages/vitnode/src/tests/content-live.ts create mode 100644 packages/vitnode/src/ws/instances.test.ts diff --git a/apps/web/content/docs/dev/websocket.mdx b/apps/web/content/docs/dev/websocket.mdx index e6f1fd7526..ed4b039cb9 100644 --- a/apps/web/content/docs/dev/websocket.mdx +++ b/apps/web/content/docs/dev/websocket.mdx @@ -108,12 +108,92 @@ export const ChatRoom = ({ roomId }: { roomId: string }) => { --- +## Rooms + +A room is a named group of browser tabs, such as everyone looking at one chat thread or editing one record. Send a message to a room and only its members get it, on every API instance. A room is any string your feature picks, for example `chat:42`. + +### Join and leave per tab + +All tabs of a browser share one socket, so a socket joins a room on behalf of a tab. Let each tab send its own random `clientId` with every message, and join or leave with it: + +```ts title="plugins/chat/src/api/modules/rooms/rooms.socket.ts" +import { buildWebSocket } from "@vitnode/core/api/lib/websocket" +import { wsRegistry } from "@vitnode/core/ws/registry" + +interface RoomMessage { + clientId: string + roomId: string + type: "join" | "leave" +} + +export const roomsSocket = buildWebSocket({ + id: "presence", + onMessage: ({ data, ws }) => { + const room = `chat:${data.roomId}` + + // [!code ++:5] + if (data.type === "join") { + wsRegistry.join(ws, room, data.clientId) + } else { + wsRegistry.leave(ws, room, data.clientId) + } + }, +}) +``` + +Register the socket on its module with `webSockets: [roomsSocket]`. The socket stays in the room until its last tab leaves, so a closed tab never cuts off the others. Check who may join (the session on `c`, a permission) before calling `join`: a room is only as private as that check. + +`join` and `leave` return `true` when they changed something. `wsRegistry.membersOf(room)` lists the members on this instance and `wsRegistry.roomsOf(ws)` lists the rooms of one socket. + +### Send to a room + +```ts +// [!code ++:4] +c.get("realtime").toRoom(`chat:${roomId}`, chatChannel, { + roomId, + message: "Hello!", + senderId: user.id, +}) +``` + +Each socket in the room receives the payload once, however many of its tabs joined. Put the room or record in the payload so every tab can ignore what is not its own. + +### Clean up when a socket closes + +A socket that closes leaves all of its rooms. Use `onConnectionClose` to update your own state, such as an "online now" list: + +```ts title="plugins/chat/src/api/modules/rooms/presence.ts" +import { wsRegistry } from "@vitnode/core/ws/registry" + +// [!code ++:5] +wsRegistry.onConnectionClose((ws, left) => { + for (const { memberId, room } of left) { + markOffline(room, memberId) + } +}) +``` + +`left` holds every `{ memberId, room }` the socket was in. Register the listener once, at module load. + +### Rooms on several instances + +With `REDIS_URL` set, `toRoom` also publishes the message over Redis, and every other instance delivers it to its own members. Room membership itself stays local to each instance. + +When an instance keeps per-room state in memory, it can follow the other instances: + +- `onRemoteRoomMessage((room, id, data) => ...)` sees room messages from other instances before they reach local sockets. A collaborative document uses it to apply the same edit to its own copy. +- `publishToInstances(topic, data)` and `onInstanceMessage(({ topic, data, origin }) => ...)` exchange data between instances that never reaches a socket, such as each instance's list of members. Republish it periodically and let entries from a silent instance expire, so a crashed instance leaves no ghosts. + +Without Redis, all of this works on a single instance with no extra setup: `toRoom` delivers locally and instance messages go nowhere. + +--- + ## Architectural Highlights - **Single Connection**: Only one `/api/ws` socket is opened per client. All features share it via message multiplexing. - **Tab Leader Election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, distributing messages across tabs via `BroadcastChannel`. - **Automatic Reconnect**: Backoff retry automatically re-establishes dropped connections within seconds. -- **Multiple Instances**: With `REDIS_URL` set, `sendToUser` and `broadcast` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. +- **Multiple Instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. ## Learn More diff --git a/packages/vitnode/src/api/lib/check-staff-permission.ts b/packages/vitnode/src/api/lib/check-staff-permission.ts index 0f532ec794..11aa3af918 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.ts @@ -226,18 +226,31 @@ export const getStaffFlags = async ( }; }; -export const checkStaffPermission = async ( +/** + * {@link checkStaffPermission} for a user the caller resolved itself, such as a + * socket that resolves its AdminCP session per message. Reads nothing from + * `c.get("admin")` or `c.get("user")`. + */ +export const checkStaffPermissionOfUser = async ( c: Context, + user: StaffUser, { type, ...args }: PermissionsStaffArgs & { type: PermissionStaffType }, ): Promise => { - const user = type === "admin" ? c.get("admin")?.user : c.get("user"); - if (!user) return false; - const set = await resolveStaffPermissions(c, { type, user }); return hasStaffPermission(set, args); }; +export const checkStaffPermission = async ( + c: Context, + args: PermissionsStaffArgs & { type: PermissionStaffType }, +): Promise => { + const user = args.type === "admin" ? c.get("admin")?.user : c.get("user"); + if (!user) return false; + + return await checkStaffPermissionOfUser(c, user, args); +}; + export const assertStaffPermission = async ( c: Context, args: PermissionsStaffArgs & { type: PermissionStaffType }, diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts index 8c8196e1cd..f45f38cdbe 100644 --- a/packages/vitnode/src/api/modules/content/content.module.ts +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { contentLiveWebSocket } from "@/content/server/live/socket"; import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron"; import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task"; @@ -11,4 +12,5 @@ export const contentModule = buildModule({ routes: [], cronJobs: [contentEditorialCleanupCron], queueTasks: [contentScheduleQueueTask, contentScheduleEffectsQueueTask], + webSockets: [contentLiveWebSocket], }); diff --git a/packages/vitnode/src/content/server/live/auth.test.ts b/packages/vitnode/src/content/server/live/auth.test.ts new file mode 100644 index 0000000000..73c6ebdcac --- /dev/null +++ b/packages/vitnode/src/content/server/live/auth.test.ts @@ -0,0 +1,297 @@ +// @vitest-environment node +import type { Context, Next } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { ContentLiveDocRef } from "@/content/live/protocol"; +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { createContentModel } from "@/content/server/model"; +import { core_languages } from "@/database/languages"; +import { + testCategoryContentType, + testEditorialNoteContentType, + testLocalizedGuideContentType, +} from "@/tests/content-fixtures"; +import { + createSessionWorld, + fakeSession, + sessionCookies, +} from "@/tests/sessions"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { + authorizeContentLive, + createContentLiveAuthCache, + isContentLiveDocValid, +} from "./auth"; + +const PLUGIN_ID = "@vitnode/example"; +const notes = createContentModel(testEditorialNoteContentType); +const categories = createContentModel(testCategoryContentType); + +/** A guide with a collaborative field, as the rich text field kind declares it. */ +const guideWithRichText = { + ...testLocalizedGuideContentType, + fields: { + ...testLocalizedGuideContentType.fields, + content: { kind: "richText", localized: true, nullable: true }, + intro: { kind: "richText", nullable: true }, + }, +} as unknown as AnyContentTypeDefinition; + +const DEVICE = { id: 3, publicId: "laptop" }; +const editor = fakeSession(7, DEVICE); +const userOnly = fakeSession(8, DEVICE, ["user"]); + +const CAN_EDIT = { + module: testEditorialNoteContentType.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: PLUGIN_ID, +}; + +const setup = async () => { + const world = await createSessionWorld({ + extraTables: [ + [ + notes.table, + [ + { + body: null, + createdAt: new Date(), + id: 1, + title: "Hello", + updatedAt: new Date(), + version: 4, + }, + ], + ], + [ + core_languages, + [ + { code: "en", default: true, id: 1, name: "English" }, + { code: "pl", default: false, id: 2, name: "Polski" }, + ], + ], + ], + sessions: [editor, userOnly], + }); + + world.app.use("*", async (c: Context, next: Next) => { + c.set("core", { + ...c.get("core"), + contentModels: [ + { model: notes, pluginId: PLUGIN_ID }, + { model: categories, pluginId: PLUGIN_ID }, + ], + contentTypes: [ + { definition: testEditorialNoteContentType, pluginId: PLUGIN_ID }, + { definition: testCategoryContentType, pluginId: PLUGIN_ID }, + { definition: guideWithRichText, pluginId: PLUGIN_ID }, + ], + i18n: { locales: [] }, + } as unknown as EnvVariablesVitNode["core"]); + await next(); + }); + world.app.get("/authorize/:type/:id", async c => + c.json( + await authorizeContentLive(c, { + contentTypeId: c.req.param("type"), + itemId: Number(c.req.param("id")), + }), + ), + ); + world.app.post("/doc", async c => + c.json({ + valid: await isContentLiveDocValid(c, await c.req.json()), + }), + ); + + const authorize = async ( + session: null | ReturnType, + room = { contentTypeId: "test.note", itemId: 1 }, + ) => { + const response = await world.app.request( + `/authorize/${room.contentTypeId}/${room.itemId}`, + session + ? { headers: { cookie: sessionCookies(session, "admin") } } + : undefined, + ); + + return (await response.json()) as Awaited< + ReturnType + >; + }; + + const isValid = async (doc: ContentLiveDocRef) => { + const response = await world.app.request("/doc", { + body: JSON.stringify(doc), + method: "POST", + }); + + return ((await response.json()) as { valid: boolean }).valid; + }; + + return { authorize, isValid, world }; +}; + +describe("authorizing a live room", () => { + it("refuses a socket without an AdminCP session", async () => { + const { authorize } = await setup(); + + expect(await authorize(null)).toEqual({ code: "FORBIDDEN", ok: false }); + expect(await authorize(userOnly)).toEqual({ + code: "FORBIDDEN", + ok: false, + }); + }); + + it("refuses an admin without can_edit on the content type", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [{ ...CAN_EDIT, permission: CONTENT_PERMISSIONS.view }], + userId: editor.userId, + }); + + expect(await authorize(editor)).toEqual({ code: "FORBIDDEN", ok: false }); + }); + + it("accepts an admin with can_edit, naming the editor and the version", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [CAN_EDIT], + userId: editor.userId, + }); + + expect(await authorize(editor)).toMatchObject({ + ok: true, + user: { id: editor.userId, name: `user-${editor.userId}` }, + version: 4, + }); + }); + + it("answers NOT_FOUND for a missing record", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [CAN_EDIT], + userId: editor.userId, + }); + + expect( + await authorize(editor, { contentTypeId: "test.note", itemId: 99 }), + ).toEqual({ code: "NOT_FOUND", ok: false }); + }); + + it("has no room for a content type without editorial workflow", async () => { + const { authorize, world } = await setup(); + await grantStaffPermissions(world.cache, { + permissions: [ + { ...CAN_EDIT, module: testCategoryContentType.permissionModule }, + ], + userId: editor.userId, + }); + + expect( + await authorize(editor, { contentTypeId: "test.category", itemId: 1 }), + ).toEqual({ code: "NOT_FOUND", ok: false }); + }); +}); + +describe("the authorization cache", () => { + const room = { contentTypeId: "test.note", itemId: 1 }; + const granted = { + ok: true as const, + user: { avatarColor: null, id: 1, name: "Anna", nameCode: null }, + version: 1, + }; + + it("checks again once a positive answer is older than the TTL", async () => { + let at = 0; + let calls = 0; + const cache = createContentLiveAuthCache({ + authorize: async () => { + calls++; + + return Promise.resolve(granted); + }, + now: () => at, + ttlMs: 60_000, + }); + const socket = {}; + const c = {} as Context; + + await cache.authorize(c, socket, room); + at = 59_000; + await cache.authorize(c, socket, room); + + expect(calls).toBe(1); + + at = 60_000; + await cache.authorize(c, socket, room); + + expect(calls).toBe(2); + }); + + it("never remembers a refusal, and keeps sockets apart", async () => { + const answers = [ + { code: "FORBIDDEN" as const, ok: false as const }, + granted, + granted, + ]; + const cache = createContentLiveAuthCache({ + authorize: async () => Promise.resolve(answers.shift() ?? granted), + }); + const c = {} as Context; + const socket = {}; + + expect((await cache.authorize(c, socket, room)).ok).toBe(false); + expect((await cache.authorize(c, socket, room)).ok).toBe(true); + expect(answers).toHaveLength(1); + + await cache.authorize(c, {}, room); + + expect(answers).toHaveLength(0); + }); +}); + +describe("a collaborative document reference", () => { + const doc = (overrides: Partial): ContentLiveDocRef => ({ + contentTypeId: "test.localized-guide", + field: "content", + itemId: 1, + locale: "en", + ...overrides, + }); + + it("accepts a localized rich text field in an existing language", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({}))).toBe(true); + expect(await isValid(doc({ locale: "pl" }))).toBe(true); + }); + + it("refuses an unknown or a non-canonical language, or none", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({ locale: "de" }))).toBe(false); + expect(await isValid(doc({ locale: "EN" }))).toBe(false); + expect(await isValid(doc({ locale: null }))).toBe(false); + }); + + it("wants no language for a shared rich text field", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({ field: "intro", locale: null }))).toBe(true); + expect(await isValid(doc({ field: "intro", locale: "en" }))).toBe(false); + }); + + it("refuses fields that are not rich text, or do not exist", async () => { + const { isValid } = await setup(); + + expect(await isValid(doc({ field: "body" }))).toBe(false); + expect(await isValid(doc({ field: "constructor" }))).toBe(false); + expect(await isValid(doc({ contentTypeId: "test.note" }))).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/server/live/auth.ts b/packages/vitnode/src/content/server/live/auth.ts new file mode 100644 index 0000000000..120bff1397 --- /dev/null +++ b/packages/vitnode/src/content/server/live/auth.ts @@ -0,0 +1,179 @@ +import type { Context } from "hono"; + +import type { + ContentLiveDocRef, + ContentLiveRoomRef, +} from "@/content/live/protocol"; +import type { RegisteredContentType } from "@/content/registry"; + +import { checkStaffPermissionOfUser } from "@/api/lib/check-staff-permission"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentLiveRoom } from "@/content/live/protocol"; +import { findContentLanguage } from "@/content/server/language-resolver"; +import { findContentModel } from "@/content/server/model"; + +/** How long a socket keeps a positive authorization for one room. */ +export const CONTENT_LIVE_AUTH_TTL_MS = 60_000; + +/** + * The field kind edited collaboratively. Compared as a string: the kind is + * declared by the rich text field, not by the live layer. + */ +export const CONTENT_LIVE_DOCUMENT_FIELD_KIND = "richText"; + +/** What presence shows about an editor. */ +export interface ContentLiveUser { + avatarColor: null | string; + id: number; + name: string; + nameCode: null | string; +} + +export type ContentLiveAuthorization = + | { code: "FORBIDDEN" | "NOT_FOUND"; ok: false } + | { + ok: true; + user: ContentLiveUser; + /** The record's version when it was checked, `null` if it has none. */ + version: null | number; + }; + +/** An editorial content type registered on this install, or `undefined`. */ +export const findLiveContentType = ( + c: Context, + contentTypeId: string, +): RegisteredContentType | undefined => + c + .get("core") + .contentTypes.find( + entry => + entry.definition.id === contentTypeId && + entry.definition.editorial.enabled, + ); + +const versionOf = (row: object): null | number => { + const version: unknown = Reflect.get(row, "version"); + + return typeof version === "number" ? version : null; +}; + +/** + * Whether the socket's AdminCP session may edit one record live. + * + * The socket only carries the user session on `c`, so the AdminCP session is + * resolved here from its cookie (it has `path=/` and rides the upgrade + * request). The permission check takes that user explicitly: `c.get("admin")` + * is never written, so nothing leaks into the next message on the socket. + */ +export const authorizeContentLive = async ( + c: Context, + room: ContentLiveRoomRef, +): Promise => { + const entry = findLiveContentType(c, room.contentTypeId); + if (!entry) return { code: "NOT_FOUND", ok: false }; + + const session = await new SessionAdminModel(c).getSession({ extend: false }); + if (!session) return { code: "FORBIDDEN", ok: false }; + + const allowed = await checkStaffPermissionOfUser(c, session.user, { + module: entry.definition.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: entry.pluginId, + type: "admin", + }); + if (!allowed) return { code: "FORBIDDEN", ok: false }; + + const model = findContentModel( + c.get("core").contentModels, + room.contentTypeId, + ); + const row = model + ? await model.model.service(c).findById(room.itemId) + : undefined; + if (!row) return { code: "NOT_FOUND", ok: false }; + + const { avatarColor, id, name, nameCode } = session.user; + + return { + ok: true, + user: { avatarColor, id, name, nameCode }, + version: versionOf(row), + }; +}; + +/** + * Remembers a positive authorization per socket and room for `ttlMs`, so a + * keystroke does not cost a session lookup, while a revoked editor still loses + * the room within a minute. Failures are never cached. + */ +export const createContentLiveAuthCache = ({ + authorize = authorizeContentLive, + now = Date.now, + ttlMs = CONTENT_LIVE_AUTH_TTL_MS, +}: { + authorize?: ( + c: Context, + room: ContentLiveRoomRef, + ) => Promise; + now?: () => number; + ttlMs?: number; +} = {}) => { + type Remembered = Map< + string, + { expiresAt: number; result: ContentLiveAuthorization } + >; + const bySocket = new WeakMap(); + + return { + authorize: async ( + c: Context, + socket: object, + room: ContentLiveRoomRef, + ): Promise => { + const key = contentLiveRoom(room); + const rooms: Remembered = bySocket.get(socket) ?? new Map(); + bySocket.set(socket, rooms); + + const cached = rooms.get(key); + if (cached && cached.expiresAt > now()) return cached.result; + + const result = await authorize(c, room); + if (result.ok) rooms.set(key, { expiresAt: now() + ttlMs, result }); + else rooms.delete(key); + + return result; + }, + forget: (socket: object, room: ContentLiveRoomRef): void => { + bySocket.get(socket)?.delete(contentLiveRoom(room)); + }, + }; +}; + +/** + * Whether a document reference names a collaborative field of the content + * type, in a locale that exists when the field is localized and with no + * locale when it is not. + */ +export const isContentLiveDocValid = async ( + c: Context, + doc: ContentLiveDocRef, +): Promise => { + const entry = findLiveContentType(c, doc.contentTypeId); + if (!entry || !Object.hasOwn(entry.definition.fields, doc.field)) { + return false; + } + + const descriptor = entry.definition.fields[doc.field]; + const kind: string = descriptor.kind; + if (kind !== CONTENT_LIVE_DOCUMENT_FIELD_KIND) return false; + + const localized = + entry.definition.localization.enabled && descriptor.localized === true; + if (!localized) return doc.locale === null; + if (doc.locale === null) return false; + + // Exact, not case-insensitive: the locale is part of the document's room, so + // "EN" and "en" would otherwise be two documents of one field. + return (await findContentLanguage(c, doc.locale))?.locale === doc.locale; +}; diff --git a/packages/vitnode/src/content/server/live/document-store.integration.test.ts b/packages/vitnode/src/content/server/live/document-store.integration.test.ts new file mode 100644 index 0000000000..ed9aa4a232 --- /dev/null +++ b/packages/vitnode/src/content/server/live/document-store.integration.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; + +import type { ContentLiveDocRef } from "@/content/live/protocol"; + +import { core_content_documents } from "@/database/content-documents"; +import { core_files } from "@/database/files"; +import { core_languages } from "@/database/languages"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { + createTestDatabase, + describePostgres, + type TestDatabaseHandle, +} from "@/tests/postgres"; + +import { + cleanupStaleContentDocuments, + CONTENT_DOCUMENT_SEED_CLAIM_MS, + createContentDocumentStore, +} from "./document-store"; + +const doc: ContentLiveDocRef = { + contentTypeId: "test.guide", + field: "content", + itemId: 1, + locale: "en", +}; +const shared: ContentLiveDocRef = { ...doc, field: "intro", locale: null }; +const otherRecord: ContentLiveDocRef = { ...doc, itemId: 2 }; + +describePostgres("the content document store", () => { + let database: TestDatabaseHandle; + let c: Context; + + beforeAll(async () => { + // The documents table references users, which reference these. + database = await createTestDatabase({ + core_content_documents, + core_files, + core_languages, + core_roles, + core_users, + }); + c = { + get: (key: string) => (key === "db" ? database.db : undefined), + } as unknown as Context; + }); + + afterAll(async () => { + await database?.drop(); + }); + + beforeEach(async () => { + await database.db.delete(core_content_documents); + }); + + it("round-trips a state, per language and for a shared field", async () => { + const store = createContentDocumentStore(c); + + await store.save(doc, new Uint8Array([1, 2, 3]), { + baseVersion: 4, + userId: null, + }); + await store.save(shared, new Uint8Array([9]), { + baseVersion: null, + userId: null, + }); + + expect(await store.load(doc)).toEqual(new Uint8Array([1, 2, 3])); + expect(await store.load(shared)).toEqual(new Uint8Array([9])); + expect(await store.load({ ...doc, locale: "pl" })).toBeNull(); + + await store.save(doc, new Uint8Array([7]), { + baseVersion: 5, + userId: null, + }); + + expect(await store.load(doc)).toEqual(new Uint8Array([7])); + }); + + it("lets exactly one of two openers claim the seed", async () => { + const first = createContentDocumentStore(c); + const second = createContentDocumentStore(c); + + const claims = await Promise.all([ + first.claimSeed(doc), + second.claimSeed(doc), + ]); + + expect(claims.filter(Boolean)).toHaveLength(1); + // A claim is not a state. + expect(await first.load(doc)).toBeNull(); + }); + + it("takes over a stale claim, but never a seeded document", async () => { + const store = createContentDocumentStore(c); + await store.claimSeed(doc); + await database.db.update(core_content_documents).set({ + updatedAt: new Date(Date.now() - CONTENT_DOCUMENT_SEED_CLAIM_MS - 1), + }); + + expect(await store.claimSeed(doc)).toBe(true); + + await store.save(doc, new Uint8Array([1]), { + baseVersion: 1, + userId: null, + }); + await database.db + .update(core_content_documents) + .set({ updatedAt: new Date(0) }); + + expect(await store.claimSeed(doc)).toBe(false); + }); + + it("releases only an unseeded claim", async () => { + const store = createContentDocumentStore(c); + await store.claimSeed(doc); + await store.save(shared, new Uint8Array([1]), { + baseVersion: 1, + userId: null, + }); + + await store.releaseSeed(doc); + await store.releaseSeed(shared); + + expect(await store.claimSeed(doc)).toBe(true); + expect(await store.load(shared)).toEqual(new Uint8Array([1])); + }); + + it("deletes one record's documents", async () => { + const store = createContentDocumentStore(c); + const meta = { baseVersion: 1, userId: null }; + await store.save(doc, new Uint8Array([1]), meta); + await store.save(shared, new Uint8Array([1]), meta); + await store.save(otherRecord, new Uint8Array([1]), meta); + + await store.deleteRecord(doc); + + expect(await store.load(doc)).toBeNull(); + expect(await store.load(shared)).toBeNull(); + expect(await store.load(otherRecord)).toEqual(new Uint8Array([1])); + }); + + it("cleans up documents untouched for 30 days", async () => { + const store = createContentDocumentStore(c); + const meta = { baseVersion: 1, userId: null }; + await store.save(doc, new Uint8Array([1]), meta); + await store.save(otherRecord, new Uint8Array([1]), meta); + const now = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000 + 1_000); + await store.save(shared, new Uint8Array([1]), meta); + await database.db + .update(core_content_documents) + .set({ updatedAt: now }) + .where(eq(core_content_documents.field, "intro")); + + expect(await cleanupStaleContentDocuments(c, { now })).toBe(2); + expect(await store.load(shared)).toEqual(new Uint8Array([1])); + }); +}); diff --git a/packages/vitnode/src/content/server/live/document-store.ts b/packages/vitnode/src/content/server/live/document-store.ts new file mode 100644 index 0000000000..0292a8dfc2 --- /dev/null +++ b/packages/vitnode/src/content/server/live/document-store.ts @@ -0,0 +1,139 @@ +import type { Context } from "hono"; + +import { and, eq, lt, sql } from "drizzle-orm"; + +import type { ContentLiveDocRef } from "@/content/live/protocol"; + +import { + CONTENT_LIVE_SHARED_LOCALE, + CONTENT_LIVE_STALE_DAYS, +} from "@/content/live/protocol"; +import { core_content_documents } from "@/database/content-documents"; + +import type { ContentDocumentStore } from "./documents"; + +/** + * A seed claim older than this is taken over by the next opener: the instance + * that held it stopped without seeding or releasing it. + */ +export const CONTENT_DOCUMENT_SEED_CLAIM_MS = 30_000; + +const table = core_content_documents; + +const languageOf = (doc: ContentLiveDocRef): string => + doc.locale ?? CONTENT_LIVE_SHARED_LOCALE; + +const whereDoc = (doc: ContentLiveDocRef) => + and( + eq(table.contentTypeId, doc.contentTypeId), + eq(table.itemId, doc.itemId), + eq(table.field, doc.field), + eq(table.language, languageOf(doc)), + ); + +const isUnseeded = sql`octet_length(${table.state}) = 0`; + +const keyOf = (doc: ContentLiveDocRef) => ({ + contentTypeId: doc.contentTypeId, + field: doc.field, + itemId: doc.itemId, + language: languageOf(doc), +}); + +const conflictTarget = [ + table.contentTypeId, + table.itemId, + table.field, + table.language, +]; + +/** The `core_content_documents` store, on the request's database handle. */ +export const createContentDocumentStore = ( + c: Context, +): ContentDocumentStore => ({ + claimSeed: async doc => { + const now = new Date(); + // One statement, so two instances cannot both win: the insert succeeds for + // the first, and the update only takes over an unseeded, stale claim. + const claimed = await c + .get("db") + .insert(table) + .values({ ...keyOf(doc), state: new Uint8Array(), updatedAt: now }) + .onConflictDoUpdate({ + set: { updatedAt: now }, + setWhere: and( + isUnseeded, + lt( + table.updatedAt, + new Date(now.getTime() - CONTENT_DOCUMENT_SEED_CLAIM_MS), + ), + ), + target: conflictTarget, + }) + .returning({ id: table.id }); + + return claimed.length > 0; + }, + deleteRecord: async room => { + await c + .get("db") + .delete(table) + .where( + and( + eq(table.contentTypeId, room.contentTypeId), + eq(table.itemId, room.itemId), + ), + ); + }, + load: async doc => { + const [row] = await c + .get("db") + .select({ state: table.state }) + .from(table) + .where(whereDoc(doc)) + .limit(1); + + return row && row.state.byteLength > 0 ? row.state : null; + }, + releaseSeed: async doc => { + await c + .get("db") + .delete(table) + .where(and(whereDoc(doc), isUnseeded)); + }, + save: async (doc, state, { baseVersion, userId }) => { + const values = { + baseVersion, + state, + updatedAt: new Date(), + updatedBy: userId, + }; + + await c + .get("db") + .insert(table) + .values({ ...keyOf(doc), ...values }) + .onConflictDoUpdate({ set: values, target: conflictTarget }); + }, +}); + +/** + * Delete collaborative documents nobody touched for + * {@link CONTENT_LIVE_STALE_DAYS} days. Saving the record already committed + * their content; a later open seeds a fresh document from the record. + */ +export const cleanupStaleContentDocuments = async ( + c: Context, + { now = new Date() }: { now?: Date } = {}, +): Promise => { + const before = new Date( + now.getTime() - CONTENT_LIVE_STALE_DAYS * 24 * 60 * 60 * 1000, + ); + const removed = await c + .get("db") + .delete(table) + .where(lt(table.updatedAt, before)) + .returning({ id: table.id }); + + return removed.length; +}; diff --git a/packages/vitnode/src/content/server/live/documents.test.ts b/packages/vitnode/src/content/server/live/documents.test.ts new file mode 100644 index 0000000000..7cf83f3e82 --- /dev/null +++ b/packages/vitnode/src/content/server/live/documents.test.ts @@ -0,0 +1,444 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +import type { ContentLiveDocRef } from "@/content/live/protocol"; + +import { + CONTENT_DOCUMENT_PERSIST_MS, + contentLiveChannel, + contentLiveDocRoom, + contentLiveRoom, +} from "@/content/live/protocol"; +import { + createLiveHarness, + type FakeLiveSocket, + ofType, +} from "@/tests/content-live"; + +import { decodeContentLiveBytes, encodeContentLiveBytes } from "./messages"; + +const room = { contentTypeId: "test.guide", itemId: 1 }; +const doc: ContentLiveDocRef = { ...room, field: "content", locale: "en" }; +const otherLocale: ContentLiveDocRef = { ...doc, locale: "pl" }; + +const harnesses: ReturnType[] = []; + +afterEach(() => { + harnesses.splice(0).forEach(live => live.stop()); + vi.useRealTimers(); +}); + +/** One browser tab with its own Y.Doc, talking to the server. */ +const setup = () => { + const live = createLiveHarness(); + harnesses.push(live); + + const tab = async ( + user: number, + clientId: string, + socket?: FakeLiveSocket, + ) => { + const target = socket ?? live.socket({ id: user }); + const ydoc = new Y.Doc(); + let seen = 0; + + await live.send(target, { clientId, locale: "en", room, type: "join" }); + + /** Apply whatever the server sent this socket since the last sync. */ + const sync = () => { + const received = target.received(); + for (const message of received.slice(seen)) { + if (message.type !== "doc:update" || message.from === clientId) { + continue; + } + Y.applyUpdate(ydoc, decodeContentLiveBytes(message.update), "server"); + } + seen = received.length; + }; + + return { + clientId, + close: async (ref = doc) => { + await live.send(target, { clientId, doc: ref, type: "doc:close" }); + }, + edit: async (change: (text: Y.Text) => void, ref = doc) => { + sync(); + const before = Y.encodeStateVector(ydoc); + change(ydoc.getText("content")); + await live.send(target, { + clientId, + doc: ref, + type: "doc:update", + update: encodeContentLiveBytes(Y.encodeStateAsUpdate(ydoc, before)), + }); + }, + open: async (ref = doc) => { + await live.send(target, { + clientId, + doc: ref, + stateVector: encodeContentLiveBytes(Y.encodeStateVector(ydoc)), + type: "doc:open", + }); + sync(); + }, + seeds: () => + ofType(target.received(), "doc:seed").filter( + seed => seed.clientId === clientId, + ), + socket: target, + sync, + text: () => { + sync(); + + return ydoc.getText("content").toJSON(); + }, + ydoc, + }; + }; + + const serverText = (ref = doc) => + live.server.documents.loaded(ref)?.getText("content").toJSON(); + + return { live, serverText, tab }; +}; + +describe("opening a document", () => { + it("requires the tab to be in the record's room", async () => { + const { live } = setup(); + const stranger = live.socket({ id: 9 }); + + await live.send(stranger, { + clientId: "x", + doc, + stateVector: "", + type: "doc:open", + }); + + expect(ofType(stranger.received(), "error")).toEqual([ + { clientId: "x", code: "NOT_JOINED", room, type: "error" }, + ]); + }); + + it("refuses a field that is not a collaborative document", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + + await anna.open({ ...doc, field: "title" }); + + expect(ofType(anna.socket.received(), "error").at(-1)?.code).toBe( + "NOT_FOUND", + ); + expect(live.server.documents.loaded({ ...doc, field: "title" })).toBe( + undefined, + ); + }); + + it("answers with what the tab misses and the server's state vector", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Hello")); + const ben = await tab(2, "ben-1"); + + await ben.open(); + + expect(ben.text()).toBe("Hello"); + expect(ofType(ben.socket.received(), "doc:state-vector")).toEqual([ + { + clientId: "ben-1", + doc, + stateVector: encodeContentLiveBytes(Y.encodeStateVector(anna.ydoc)), + type: "doc:state-vector", + }, + ]); + }); +}); + +describe("editing together", () => { + it("converges two tabs and the server on the same text", async () => { + const { serverText, tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + await anna.open(); + await ben.open(); + + await anna.edit(text => text.insert(0, "Hello")); + await ben.edit(text => text.insert(text.length, " world")); + await Promise.all([ + anna.edit(text => text.insert(0, "> ")), + ben.edit(text => text.insert(text.length, "!")), + ]); + + expect(anna.text()).toBe("> Hello world!"); + expect(ben.text()).toBe(anna.text()); + expect(serverText()).toBe(anna.text()); + }); + + it("relays updates only to the document's room, marked with the sender", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + const elsewhere = await tab(3, "cleo-1"); + await anna.open(); + await ben.open(); + await elsewhere.open(otherLocale); + const outsider = live.socket({ id: 4 }); + + await anna.edit(text => text.insert(0, "Hi")); + + const relayed = ofType(ben.socket.received(), "doc:update").at(-1); + expect(relayed).toMatchObject({ doc, from: "anna-1" }); + expect( + ofType(elsewhere.socket.received(), "doc:update").filter( + update => update.from === "anna-1", + ), + ).toEqual([]); + expect(outsider.received()).toEqual([]); + }); + + it("rejects an update from a tab that did not open the document", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + + await anna.edit(text => text.insert(0, "Hi")); + + expect(ofType(anna.socket.received(), "error").at(-1)?.code).toBe( + "NOT_JOINED", + ); + expect(live.server.documents.loaded(doc)).toBe(undefined); + }); + + it("applies an update another instance relayed", async () => { + const { live, serverText, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Hello")); + + const remote = new Y.Doc(); + Y.applyUpdate(remote, Y.encodeStateAsUpdate(anna.ydoc)); + const before = Y.encodeStateVector(remote); + remote.getText("content").insert(5, " from afar"); + live.server.onRemoteRoomMessage( + contentLiveDocRoom(doc), + contentLiveChannel.id, + { + doc, + from: "remote-tab", + type: "doc:update", + update: encodeContentLiveBytes(Y.encodeStateAsUpdate(remote, before)), + }, + ); + + expect(serverText()).toBe("Hello from afar"); + }); + + it("gives a newcomer everyone's carets and drops a leaver's", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + const annaAwareness = new Awareness(anna.ydoc); + annaAwareness.setLocalState({ user: { name: "Anna" } }); + await live.send(anna.socket, { + clientId: "anna-1", + doc, + type: "doc:awareness", + update: encodeContentLiveBytes( + encodeAwarenessUpdate(annaAwareness, [anna.ydoc.clientID]), + ), + }); + annaAwareness.destroy(); + const ben = await tab(2, "ben-1"); + + await ben.open(); + + const benAwareness = new Awareness(ben.ydoc); + const apply = () => { + for (const message of ofType(ben.socket.received(), "doc:awareness")) { + applyAwarenessUpdate( + benAwareness, + decodeContentLiveBytes(message.update), + "server", + ); + } + }; + apply(); + + expect(benAwareness.getStates().get(anna.ydoc.clientID)).toEqual({ + user: { name: "Anna" }, + }); + + await anna.close(); + apply(); + + expect(benAwareness.getStates().has(anna.ydoc.clientID)).toBe(false); + benAwareness.destroy(); + }); +}); + +describe("seeding an empty document", () => { + it("asks exactly one tab, the first to open", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + + await anna.open(); + await ben.open(); + + expect(anna.seeds()).toEqual([ + { clientId: "anna-1", doc, type: "doc:seed" }, + ]); + expect(ben.seeds()).toEqual([]); + }); + + it("asks the next tab when the seeder leaves before seeding", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + await anna.open(); + await ben.open(); + + await anna.close(); + + expect(ben.seeds()).toHaveLength(1); + }); + + it("asks nobody once the document has content", async () => { + const { tab } = setup(); + const anna = await tab(1, "anna-1"); + const ben = await tab(2, "ben-1"); + const cleo = await tab(3, "cleo-1"); + await anna.open(); + await ben.open(); + await anna.edit(text => text.insert(0, "Seeded")); + + await anna.close(); + await cleo.open(); + + expect(ben.seeds()).toEqual([]); + expect(cleo.seeds()).toEqual([]); + }); + + it("asks nobody when the seed is claimed elsewhere", async () => { + const { live, tab } = setup(); + await live.store.store.claimSeed(doc); + const anna = await tab(1, "anna-1"); + + await anna.open(); + + expect(anna.seeds()).toEqual([]); + + // The other instance gave up its claim: the next sweep picks a tab here. + await live.store.store.releaseSeed(doc); + await live.server.sweep(); + + expect(anna.seeds()).toHaveLength(1); + }); +}); + +describe("persistence", () => { + it("writes once after a quiet period, not per keystroke", async () => { + vi.useFakeTimers(); + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + + await anna.edit(text => text.insert(0, "H")); + await anna.edit(text => text.insert(1, "i")); + await vi.advanceTimersByTimeAsync(CONTENT_DOCUMENT_PERSIST_MS - 1); + + expect(live.store.saves).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + + expect(live.store.saves).toEqual([{ doc, userId: 1 }]); + }); + + it("writes when the last tab closes, unloads, and reloads the same text", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Kept")); + + await anna.close(); + + expect(live.store.has(doc)).toBe(true); + expect(live.server.documents.loaded(doc)).toBe(undefined); + + const ben = await tab(2, "ben-1"); + await ben.open(); + + expect(ben.text()).toBe("Kept"); + expect(ben.seeds()).toEqual([]); + }); + + it("stores nothing for a document nobody seeded, and frees its claim", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + + await anna.close(); + + expect(live.store.has(doc)).toBe(false); + expect(live.store.claims.size).toBe(0); + }); + + it("closes a socket's documents when it disconnects", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Saved on close")); + + live.disconnect(anna.socket); + await vi.waitFor(() => { + expect(live.server.documents.loaded(doc)).toBe(undefined); + }); + + expect(live.store.has(doc)).toBe(true); + }); +}); + +describe("resetting a record", () => { + it("deletes and unloads its documents, so tabs must open them again", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + await anna.edit(text => text.insert(0, "Old")); + await anna.close(); + await anna.open(); + + await live.server.onReset({ c: anna.socket.c, room }); + + expect(live.store.has(doc)).toBe(false); + expect(live.server.documents.loaded(doc)).toBe(undefined); + + await anna.edit(text => text.insert(0, "x")); + + expect(ofType(anna.socket.received(), "error").at(-1)?.code).toBe( + "NOT_JOINED", + ); + }); + + it("unloads documents when another instance resets the record", async () => { + const { live, tab } = setup(); + const anna = await tab(1, "anna-1"); + await anna.open(); + + live.server.onRemoteRoomMessage( + contentLiveRoom(room), + contentLiveChannel.id, + { + reason: "restored", + room, + type: "reset", + }, + ); + + expect(live.server.documents.loaded(doc)).toBe(undefined); + }); +}); diff --git a/packages/vitnode/src/content/server/live/documents.ts b/packages/vitnode/src/content/server/live/documents.ts new file mode 100644 index 0000000000..f43be7cc05 --- /dev/null +++ b/packages/vitnode/src/content/server/live/documents.ts @@ -0,0 +1,568 @@ +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, + removeAwarenessStates, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +import type { + ContentLiveDocRef, + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_DOCUMENT_PERSIST_MS, + contentLiveDocRoom, + sameContentLiveRoom, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_MAX_UPDATE_BYTES, + decodeContentLiveBytes, + encodeContentLiveBytes, +} from "./messages"; + +/** Where documents live between sessions. The database in production. */ +export interface ContentDocumentStore { + /** + * Reserve the right to seed an empty document. Atomic across instances: only + * one wins until the seed is stored or the claim goes stale. + */ + claimSeed: (doc: ContentLiveDocRef) => Promise; + /** Delete every document of one record. */ + deleteRecord: (room: ContentLiveRoomRef) => Promise; + /** The stored state, or `null` when there is none (or only a seed claim). */ + load: (doc: ContentLiveDocRef) => Promise; + /** Give up a seed claim nobody used, so another instance can seed. */ + releaseSeed: (doc: ContentLiveDocRef) => Promise; + save: ( + doc: ContentLiveDocRef, + state: Uint8Array, + meta: { baseVersion: null | number; userId: null | number }, + ) => Promise; +} + +export type ContentDocumentResult = "INVALID_MESSAGE" | "NOT_JOINED" | "ok"; + +/** Marks updates that came from another instance, so they are not relayed again. */ +const REMOTE_ORIGIN = Symbol("content-live-remote"); +/** Marks awareness changes the server makes itself (a member that left). */ +const SERVER_ORIGIN = Symbol("content-live-server"); + +interface DocMember { + awarenessIds: Set; + connection: TConnection; +} + +interface LoadedDoc { + awareness: Awareness; + baseVersion: null | number; + /** This instance holds the seed claim of the (still empty) document. */ + claimed: boolean; + dirty: boolean; + doc: Y.Doc; + members: Map>; + /** Nothing is stored and nobody has typed yet: a client must seed it. */ + needsSeed: boolean; + persistTimer: ReturnType | undefined; + ref: ContentLiveDocRef; + room: string; + seeder: null | string; + store: ContentDocumentStore; + updatedBy: null | number; +} + +const isEmptyDoc = (doc: Y.Doc): boolean => doc.store.clients.size === 0; + +const unrefTimer = (timer: unknown): void => { + if ( + typeof timer === "object" && + timer !== null && + "unref" in timer && + typeof timer.unref === "function" + ) { + timer.unref(); + } +}; + +const logError = (message: string, error: unknown): void => { + // eslint-disable-next-line no-console + console.error(message, error); +}; + +const asRelayedUpdate = ( + data: unknown, +): null | { type: string; update: string } => { + if (typeof data !== "object" || data === null) return null; + const type: unknown = Reflect.get(data, "type"); + const update: unknown = Reflect.get(data, "update"); + if (typeof type !== "string" || typeof update !== "string") return null; + + return { type, update }; +}; + +/** + * The collaborative rich text documents loaded on this instance: one `Y.Doc` + * and one `Awareness` per document, loaded on the first open and unloaded + * (after a last persist) when the last local member closes it. + * + * Updates are applied here, relayed to the document's room on every instance, + * and persisted after `persistMs` of quiet. Other instances apply the same + * relayed updates to their own copy through {@link applyRemote}. + */ +export const createContentDocuments = ({ + join, + leave, + maxUpdateBytes = CONTENT_LIVE_MAX_UPDATE_BYTES, + persistMs = CONTENT_DOCUMENT_PERSIST_MS, + relay, + send, +}: { + /** Add the member to the document's registry room. */ + join: (connection: TConnection, room: string, clientId: string) => void; + /** Remove the member from the document's registry room. */ + leave: (connection: TConnection, room: string, clientId: string) => void; + maxUpdateBytes?: number; + persistMs?: number; + /** Deliver to everyone in a document room, on every instance. */ + relay: (room: string, message: ContentLiveServerMessage) => void; + /** Deliver to one socket. */ + send: (connection: TConnection, message: ContentLiveServerMessage) => void; +}) => { + const docs = new Map>(); + const loading = new Map>>(); + + const trackAwareness = (entry: LoadedDoc): void => { + entry.awareness.on( + "update", + ( + { + added, + removed, + updated, + }: { added: number[]; removed: number[]; updated: number[] }, + origin: unknown, + ) => { + if (typeof origin !== "string") return; + const member = entry.members.get(origin); + if (!member) return; + for (const id of [...added, ...updated]) member.awarenessIds.add(id); + for (const id of removed) member.awarenessIds.delete(id); + }, + ); + }; + + const load = async ( + ref: ContentLiveDocRef, + store: ContentDocumentStore, + baseVersion: null | number, + ): Promise> => { + const room = contentLiveDocRoom(ref); + const existing = docs.get(room); + if (existing) return existing; + + const pending = loading.get(room); + if (pending) return await pending; + + const promise = (async () => { + const state = await store.load(ref); + const doc = new Y.Doc({ gc: true }); + if (state && state.byteLength > 0) Y.applyUpdate(doc, state); + + const awareness = new Awareness(doc); + // The server is a relay, not a peer: it has no caret of its own. + awareness.setLocalState(null); + unrefTimer(awareness._checkInterval); + + const entry: LoadedDoc = { + awareness, + baseVersion, + claimed: false, + dirty: false, + doc, + members: new Map(), + needsSeed: isEmptyDoc(doc), + persistTimer: undefined, + ref, + room, + seeder: null, + store, + updatedBy: null, + }; + trackAwareness(entry); + docs.set(room, entry); + + return entry; + })(); + + loading.set(room, promise); + try { + return await promise; + } finally { + loading.delete(room); + } + }; + + const persist = async (entry: LoadedDoc): Promise => { + clearTimeout(entry.persistTimer); + entry.persistTimer = undefined; + // An empty document is never stored: it would read back as "seeded". + if (!entry.dirty || isEmptyDoc(entry.doc)) return; + + entry.dirty = false; + try { + await entry.store.save(entry.ref, Y.encodeStateAsUpdate(entry.doc), { + baseVersion: entry.baseVersion, + userId: entry.updatedBy, + }); + entry.claimed = false; + } catch (error) { + entry.dirty = true; + logError("Content document persist error:", error); + } + }; + + const schedulePersist = (entry: LoadedDoc): void => { + entry.dirty = true; + clearTimeout(entry.persistTimer); + entry.persistTimer = setTimeout(() => { + void persist(entry); + }, persistMs); + unrefTimer(entry.persistTimer); + }; + + const unload = (entry: LoadedDoc): void => { + clearTimeout(entry.persistTimer); + entry.persistTimer = undefined; + entry.awareness.destroy(); + entry.doc.destroy(); + if (docs.get(entry.room) === entry) docs.delete(entry.room); + }; + + const markSeeded = (entry: LoadedDoc): void => { + if (!entry.needsSeed || isEmptyDoc(entry.doc)) return; + entry.needsSeed = false; + entry.seeder = null; + }; + + /** Ask one local member to fill the empty document from the record. */ + const pickSeeder = async (entry: LoadedDoc): Promise => { + if (!entry.needsSeed || entry.seeder !== null) return; + if (entry.members.size === 0) return; + + if (!entry.claimed) { + try { + entry.claimed = await entry.store.claimSeed(entry.ref); + } catch (error) { + logError("Content document seed claim error:", error); + } + } + // The claim was awaited: someone may have seeded or picked meanwhile. + if (!entry.claimed || !entry.needsSeed || entry.seeder !== null) return; + + const [first] = entry.members; + if (!first) return; + const [clientId, member] = first; + entry.seeder = clientId; + send(member.connection, { clientId, doc: entry.ref, type: "doc:seed" }); + }; + + const memberOf = ( + ref: ContentLiveDocRef, + clientId: string, + connection: TConnection, + ): LoadedDoc | undefined => { + const entry = docs.get(contentLiveDocRoom(ref)); + + return entry?.members.get(clientId)?.connection === connection + ? entry + : undefined; + }; + + const decode = (value: string): null | Uint8Array => { + const bytes = decodeContentLiveBytes(value); + + return bytes.byteLength > maxUpdateBytes ? null : bytes; + }; + + const closeEntry = async ( + entry: LoadedDoc, + clientId: string, + ): Promise => { + const member = entry.members.get(clientId); + if (!member) return; + + entry.members.delete(clientId); + leave(member.connection, entry.room, clientId); + + const ids = [...member.awarenessIds].filter(id => + entry.awareness.states.has(id), + ); + if (ids.length > 0) { + removeAwarenessStates(entry.awareness, ids, SERVER_ORIGIN); + relay(entry.room, { + doc: entry.ref, + from: null, + type: "doc:awareness", + update: encodeContentLiveBytes( + encodeAwarenessUpdate(entry.awareness, ids), + ), + }); + } + + if (entry.seeder === clientId) { + entry.seeder = null; + await pickSeeder(entry); + } + + if (entry.members.size > 0) return; + + await persist(entry); + // Someone may have opened it while the state was being written. + if (entry.members.size > 0) return; + + if (entry.claimed && entry.needsSeed) { + entry.claimed = false; + try { + await entry.store.releaseSeed(entry.ref); + } catch (error) { + logError("Content document seed release error:", error); + } + } + if (entry.members.size === 0) unload(entry); + }; + + return { + /** Apply a document message another instance relayed. */ + applyRemote: (room: string, data: unknown): void => { + const entry = docs.get(room); + const message = asRelayedUpdate(data); + if (!entry || !message) return; + + try { + const bytes = decodeContentLiveBytes(message.update); + if (message.type === "doc:update") { + Y.applyUpdate(entry.doc, bytes, REMOTE_ORIGIN); + markSeeded(entry); + // Kept dirty here too: if the instance that took the update dies + // before writing it, this copy still reaches the database. + schedulePersist(entry); + } else if (message.type === "doc:awareness") { + applyAwarenessUpdate(entry.awareness, bytes, REMOTE_ORIGIN); + } + } catch (error) { + logError("Content document remote update error:", error); + } + }, + awareness: ({ + clientId, + connection, + doc, + update, + }: { + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + update: string; + }): ContentDocumentResult => { + const entry = memberOf(doc, clientId, connection); + if (!entry) return "NOT_JOINED"; + const bytes = decode(update); + if (!bytes) return "INVALID_MESSAGE"; + + try { + applyAwarenessUpdate(entry.awareness, bytes, clientId); + } catch { + return "INVALID_MESSAGE"; + } + relay(entry.room, { + doc: entry.ref, + from: clientId, + type: "doc:awareness", + update, + }); + + return "ok"; + }, + /** Close one member's document. Unloads it after the last local member. */ + close: async ({ + clientId, + connection, + doc, + }: { + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + }): Promise => { + const entry = memberOf(doc, clientId, connection); + if (entry) await closeEntry(entry, clientId); + }, + /** Close every document of a record one member has open. */ + closeClient: async ( + room: ContentLiveRoomRef, + clientId: string, + ): Promise => { + const entries = [...docs.values()].filter( + entry => + sameContentLiveRoom(entry.ref, room) && entry.members.has(clientId), + ); + await Promise.all( + entries.map(async entry => await closeEntry(entry, clientId)), + ); + }, + /** Close a member's document by its registry room name (a closed socket). */ + closeRoom: async ( + room: string, + clientId: string, + connection: TConnection, + ): Promise => { + const entry = docs.get(room); + if (entry?.members.get(clientId)?.connection === connection) { + await closeEntry(entry, clientId); + } + }, + /** Write every dirty document now. */ + flush: async (): Promise => { + await Promise.all( + [...docs.values()].map(async entry => await persist(entry)), + ); + }, + isOpen: (doc: ContentLiveDocRef, clientId: string): boolean => + docs.get(contentLiveDocRoom(doc))?.members.has(clientId) ?? false, + /** The loaded copy of a document, for tests and diagnostics. */ + loaded: (doc: ContentLiveDocRef): undefined | Y.Doc => + docs.get(contentLiveDocRoom(doc))?.doc, + open: async ({ + baseVersion, + clientId, + connection, + doc, + stateVector, + store, + userId, + }: { + baseVersion: null | number; + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + stateVector: string; + store: ContentDocumentStore; + userId: number; + }): Promise => { + const vector = decode(stateVector); + if (!vector) return "INVALID_MESSAGE"; + if (vector.byteLength > 0) { + try { + Y.decodeStateVector(vector); + } catch { + return "INVALID_MESSAGE"; + } + } + + const entry = await load(doc, store, baseVersion); + const missing = Y.encodeStateAsUpdate( + entry.doc, + vector.byteLength > 0 ? vector : undefined, + ); + + // The tab moved to another socket (the browser elected a new leader). + const previous = entry.members.get(clientId); + if (previous && previous.connection !== connection) { + leave(previous.connection, entry.room, clientId); + } + entry.members.set(clientId, { + awarenessIds: previous?.awarenessIds ?? new Set(), + connection, + }); + entry.baseVersion = baseVersion ?? entry.baseVersion; + entry.updatedBy ??= userId; + join(connection, entry.room, clientId); + + send(connection, { + doc: entry.ref, + from: null, + type: "doc:update", + update: encodeContentLiveBytes(missing), + }); + send(connection, { + clientId, + doc: entry.ref, + stateVector: encodeContentLiveBytes(Y.encodeStateVector(entry.doc)), + type: "doc:state-vector", + }); + const peers = [...entry.awareness.states.keys()]; + if (peers.length > 0) { + send(connection, { + doc: entry.ref, + from: null, + type: "doc:awareness", + update: encodeContentLiveBytes( + encodeAwarenessUpdate(entry.awareness, peers), + ), + }); + } + + await pickSeeder(entry); + + return "ok"; + }, + /** Throw away a record's loaded documents without writing them. */ + resetRecord: (room: ContentLiveRoomRef): void => { + for (const entry of [...docs.values()]) { + if (!sameContentLiveRoom(entry.ref, room)) continue; + for (const [clientId, member] of entry.members) { + leave(member.connection, entry.room, clientId); + } + entry.members.clear(); + unload(entry); + } + }, + /** Pick a seeder for empty documents whose claim was taken elsewhere. */ + retrySeeding: async (): Promise => { + await Promise.all( + [...docs.values()].map(async entry => await pickSeeder(entry)), + ); + }, + update: ({ + clientId, + connection, + doc, + update, + userId, + }: { + clientId: string; + connection: TConnection; + doc: ContentLiveDocRef; + update: string; + userId: number; + }): ContentDocumentResult => { + const entry = memberOf(doc, clientId, connection); + if (!entry) return "NOT_JOINED"; + const bytes = decode(update); + if (!bytes) return "INVALID_MESSAGE"; + + try { + Y.applyUpdate(entry.doc, bytes, clientId); + } catch { + return "INVALID_MESSAGE"; + } + markSeeded(entry); + entry.updatedBy = userId; + relay(entry.room, { + doc: entry.ref, + from: clientId, + type: "doc:update", + update, + }); + schedulePersist(entry); + + return "ok"; + }, + }; +}; + +export type ContentDocuments = ReturnType< + typeof createContentDocuments +>; diff --git a/packages/vitnode/src/content/server/live/messages.ts b/packages/vitnode/src/content/server/live/messages.ts new file mode 100644 index 0000000000..53a0ee0ba0 --- /dev/null +++ b/packages/vitnode/src/content/server/live/messages.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import type { ContentLiveClientMessage } from "@/content/live/protocol"; + +/** The largest Yjs or awareness update one message may carry, decoded. */ +export const CONTENT_LIVE_MAX_UPDATE_BYTES = 2 * 1024 * 1024; + +const MAX_BASE64_LENGTH = Math.ceil(CONTENT_LIVE_MAX_UPDATE_BYTES / 3) * 4; + +const clientId = z.string().min(1).max(64); +const locale = z.string().min(1).max(35).nullable(); +const base64 = z + .string() + .max(MAX_BASE64_LENGTH) + .regex(/^[A-Za-z0-9+/]*={0,2}$/); + +const room = z.strictObject({ + contentTypeId: z.string().min(1).max(100), + itemId: z.number().int().positive(), +}); + +const doc = z.strictObject({ + contentTypeId: z.string().min(1).max(100), + field: z.string().min(1).max(100), + itemId: z.number().int().positive(), + locale, +}); + +/** The runtime check of every message a client sends on the live channel. */ +export const contentLiveClientMessageSchema = z.discriminatedUnion("type", [ + z.strictObject({ clientId, locale, room, type: z.literal("join") }), + z.strictObject({ + clientId, + room, + type: z.enum(["heartbeat", "leave"]), + }), + z.strictObject({ + clientId, + field: z.string().min(1).max(100).nullable(), + locale, + room, + type: z.literal("focus"), + }), + z.strictObject({ + clientId, + doc, + stateVector: base64, + type: z.literal("doc:open"), + }), + z.strictObject({ + clientId, + doc, + type: z.enum(["doc:awareness", "doc:update"]), + update: base64, + }), + z.strictObject({ clientId, doc, type: z.literal("doc:close") }), +]) satisfies z.ZodType; + +/** The `clientId` of a message, even a malformed one, to address the error. */ +export const clientIdOf = (data: unknown): string => { + if (typeof data !== "object" || data === null || !("clientId" in data)) { + return ""; + } + const { success, data: parsed } = clientId.safeParse(data.clientId); + + return success ? parsed : ""; +}; + +export const decodeContentLiveBytes = (value: string): Uint8Array => + new Uint8Array(Buffer.from(value, "base64")); + +export const encodeContentLiveBytes = (value: Uint8Array): string => + Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString( + "base64", + ); diff --git a/packages/vitnode/src/content/server/live/presence.test.ts b/packages/vitnode/src/content/server/live/presence.test.ts new file mode 100644 index 0000000000..b2fa5ae08d --- /dev/null +++ b/packages/vitnode/src/content/server/live/presence.test.ts @@ -0,0 +1,531 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; + +import type { ContentLiveMember } from "@/content/live/protocol"; + +import { CONTENT_LIVE_MEMBER_TIMEOUT_MS } from "@/content/live/protocol"; +import { createLiveHarness, ofType } from "@/tests/content-live"; + +import type { ContentLivePresenceSnapshot } from "./presence"; + +import { CONTENT_LIVE_PRESENCE_TOPIC } from "./server"; + +const room = { contentTypeId: "test.note", itemId: 1 }; + +const harnesses: ReturnType[] = []; +const harness = (options?: Parameters[0]) => { + const created = createLiveHarness(options); + harnesses.push(created); + + return created; +}; + +afterEach(() => { + harnesses.splice(0).forEach(created => created.stop()); +}); + +const lastPresence = (messages: Parameters[0]) => + ofType(messages, "presence") + .at(-1) + ?.members.map(({ clientId, field, locale }) => ({ + clientId, + field, + locale, + })); + +describe("joining a record", () => { + it("replies joined to the tab and shows everyone the new member", async () => { + const live = harness(); + const anna = live.socket({ id: 1, name: "Anna" }); + const ben = live.socket({ id: 2, name: "Ben" }); + + await live.send(anna, { + clientId: "anna-1", + locale: "en", + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: "pl", + room, + type: "join", + }); + + expect(ofType(ben.received(), "joined")).toEqual([ + { + clientId: "ben-1", + members: [ + expect.objectContaining({ + clientId: "anna-1", + name: "Anna", + userId: 1, + }), + expect.objectContaining({ + clientId: "ben-1", + name: "Ben", + userId: 2, + }), + ], + room, + type: "joined", + }, + ]); + expect(lastPresence(anna.received())).toEqual([ + { clientId: "anna-1", field: null, locale: "en" }, + { clientId: "ben-1", field: null, locale: "pl" }, + ]); + }); + + it("refuses a tab the authorization refuses, and keeps it out", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const intruder = live.socket({ id: 2 }); + live.deny(intruder); + + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(intruder, { + clientId: "x", + locale: null, + room, + type: "join", + }); + + expect(ofType(intruder.received(), "error")).toEqual([ + { clientId: "x", code: "FORBIDDEN", room, type: "error" }, + ]); + expect(live.server.presence.membersOf(room).map(m => m.clientId)).toEqual([ + "anna-1", + ]); + }); + + it("lets a tab move to a new socket, but nobody else take its clientId", async () => { + const live = harness(); + const annaOld = live.socket({ id: 1 }); + const annaNew = live.socket({ id: 1 }); + const mallory = live.socket({ id: 2 }); + await live.send(annaOld, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + + await live.send(mallory, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + + expect(ofType(mallory.received(), "error").at(-1)?.code).toBe("FORBIDDEN"); + expect(live.server.presence.get(room, "anna-1")?.member.userId).toBe(1); + + await live.send(annaNew, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + live.disconnect(annaOld); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(live.server.presence.membersOf(room).map(m => m.clientId)).toEqual([ + "anna-1", + ]); + expect(live.userLeft).toEqual([]); + }); + + it("answers a malformed message with INVALID_MESSAGE", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + + await live.send(anna, { + clientId: "anna-1", + room: { itemId: "1" }, + type: "join", + }); + await live.send(anna, { type: "dance" }); + + expect(ofType(anna.received(), "error")).toEqual([ + { clientId: "anna-1", code: "INVALID_MESSAGE", type: "error" }, + { clientId: "", code: "INVALID_MESSAGE", type: "error" }, + ]); + }); +}); + +describe("focus", () => { + it("tells the room which field and language a member is in", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: "en", + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: "en", + room, + type: "join", + }); + + await live.send(anna, { + clientId: "anna-1", + field: "title", + locale: "pl", + room, + type: "focus", + }); + + expect(lastPresence(ben.received())).toEqual([ + { clientId: "anna-1", field: "title", locale: "pl" }, + { clientId: "ben-1", field: null, locale: "en" }, + ]); + }); + + it("is applied after a join sent right before it, without waiting", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + + await Promise.all([ + live.send(anna, { clientId: "anna-1", locale: "en", room, type: "join" }), + live.send(anna, { + clientId: "anna-1", + field: "title", + locale: "en", + room, + type: "focus", + }), + ]); + + expect(lastPresence(anna.received())).toEqual([ + { clientId: "anna-1", field: "title", locale: "en" }, + ]); + }); + + it("is NOT_JOINED for a tab outside the room, or another socket's tab", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: "en", + room, + type: "join", + }); + + await live.send(ben, { + clientId: "anna-1", + field: "title", + locale: "en", + room, + type: "focus", + }); + + expect(ofType(ben.received(), "error")).toEqual([ + { clientId: "anna-1", code: "NOT_JOINED", room, type: "error" }, + ]); + expect(live.server.presence.membersOf(room)[0]?.field).toBeNull(); + }); +}); + +describe("leaving", () => { + it("removes the tab and reports the user gone after their last tab", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(anna, { + clientId: "anna-2", + locale: null, + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + await live.send(anna, { clientId: "anna-1", room, type: "leave" }); + + expect(live.userLeft).toEqual([]); + expect(lastPresence(ben.received())?.map(m => m.clientId)).toEqual([ + "anna-2", + "ben-1", + ]); + + await live.send(anna, { clientId: "anna-2", room, type: "leave" }); + + expect(live.userLeft).toEqual([{ room, userId: 1 }]); + expect(lastPresence(ben.received())?.map(m => m.clientId)).toEqual([ + "ben-1", + ]); + }); + + it("removes every tab of a socket that closes", async () => { + const live = harness(); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(anna, { + clientId: "anna-2", + locale: null, + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + live.disconnect(anna); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(lastPresence(ben.received())?.map(m => m.clientId)).toEqual([ + "ben-1", + ]); + expect(live.userLeft).toEqual([{ room, userId: 1 }]); + }); + + it("drops a member whose heartbeats stopped", async () => { + let now = 0; + const live = harness({ now: () => now }); + const anna = live.socket({ id: 1 }); + const ben = live.socket({ id: 2 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await live.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + now = CONTENT_LIVE_MEMBER_TIMEOUT_MS - 1_000; + await live.send(ben, { clientId: "ben-1", room, type: "heartbeat" }); + now = CONTENT_LIVE_MEMBER_TIMEOUT_MS; + await live.server.sweep(); + + expect(live.server.presence.membersOf(room).map(m => m.clientId)).toEqual([ + "ben-1", + ]); + expect(live.userLeft).toEqual([{ room, userId: 1 }]); + + await live.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + + expect(ofType(anna.received(), "error").at(-1)?.code).toBe("NOT_JOINED"); + }); + + it("removes a member whose access was revoked, once the cache expires", async () => { + let now = 0; + const live = harness({ authTtlMs: 60_000, now: () => now }); + const anna = live.socket({ id: 1 }); + await live.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + + live.deny(anna); + await live.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + + expect(live.server.presence.membersOf(room)).toHaveLength(1); + + now = 60_000; + await live.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + + expect(ofType(anna.received(), "error").at(-1)?.code).toBe("FORBIDDEN"); + expect(live.server.presence.membersOf(room)).toEqual([]); + }); +}); + +describe("members on another instance", () => { + /** Two servers, each instance telling the other through a fake Redis. */ + const twoInstances = () => { + let now = 0; + const delivered: { data: unknown; room: string }[] = []; + const rooms = new Map>(); + const peers: { + first?: ReturnType; + second?: ReturnType; + } = {}; + + const forward = + ( + origin: string, + target: () => ReturnType | undefined, + ) => + (snapshot: ContentLivePresenceSnapshot) => { + target()?.server.onInstanceMessage({ + data: JSON.parse(JSON.stringify(snapshot)), + origin, + topic: CONTENT_LIVE_PRESENCE_TOPIC, + }); + }; + + const first = harness({ + now: () => now, + publish: forward("a", () => peers.second), + }); + const second = harness({ + now: () => now, + publish: forward("b", () => peers.first), + // The second instance has its own sockets, so its own registry. + registry: { + join: (_ws, room, memberId) => { + const members = rooms.get(room) ?? new Set(); + rooms.set(room, members); + members.add(memberId); + + return true; + }, + leave: (_ws, room, memberId) => + rooms.get(room)?.delete(memberId) ?? false, + toRoom: (room, _id, data) => { + delivered.push({ data, room }); + }, + }, + }); + peers.first = first; + peers.second = second; + + return { + delivered, + first, + second, + setNow: (value: number) => { + now = value; + }, + }; + }; + + const ids = (members: ContentLiveMember[] | undefined) => + members?.map(member => member.clientId); + + it("lists the union of both instances' members", async () => { + const { first, second } = twoInstances(); + const anna = first.socket({ id: 1 }); + const ben = second.socket({ id: 2 }); + + await first.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await second.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + expect(ids(ofType(ben.received(), "joined")[0]?.members)).toEqual([ + "ben-1", + "anna-1", + ]); + expect(ids(ofType(anna.received(), "presence").at(-1)?.members)).toEqual([ + "anna-1", + "ben-1", + ]); + + await second.send(ben, { + clientId: "ben-1", + field: "title", + locale: null, + room, + type: "focus", + }); + + expect( + ofType(anna.received(), "presence") + .at(-1) + ?.members.find(member => member.clientId === "ben-1")?.field, + ).toBe("title"); + + await second.send(ben, { clientId: "ben-1", room, type: "leave" }); + + expect(ids(ofType(anna.received(), "presence").at(-1)?.members)).toEqual([ + "anna-1", + ]); + }); + + it("tells a newcomer instance about its members right away", async () => { + const { delivered, first, second } = twoInstances(); + const anna = first.socket({ id: 1 }); + await first.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + const ben = second.socket({ id: 2 }); + + await second.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + const latest = delivered.at(-1)?.data as { members: ContentLiveMember[] }; + expect(ids(latest.members)).toEqual(["ben-1", "anna-1"]); + }); + + it("forgets another instance's members when it stops refreshing them", async () => { + const { first, second, setNow } = twoInstances(); + const anna = first.socket({ id: 1 }); + const ben = second.socket({ id: 2 }); + await first.send(anna, { + clientId: "anna-1", + locale: null, + room, + type: "join", + }); + await second.send(ben, { + clientId: "ben-1", + locale: null, + room, + type: "join", + }); + + // The second instance dies: no refresh, no goodbye. The first one keeps + // its own member alive. + second.server.stop(); + setNow(CONTENT_LIVE_MEMBER_TIMEOUT_MS - 1_000); + await first.send(anna, { clientId: "anna-1", room, type: "heartbeat" }); + setNow(CONTENT_LIVE_MEMBER_TIMEOUT_MS); + await first.server.sweep(); + + expect(ids(ofType(anna.received(), "presence").at(-1)?.members)).toEqual([ + "anna-1", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/live/presence.ts b/packages/vitnode/src/content/server/live/presence.ts new file mode 100644 index 0000000000..e74155c477 --- /dev/null +++ b/packages/vitnode/src/content/server/live/presence.ts @@ -0,0 +1,259 @@ +import type { + ContentLiveMember, + ContentLiveRoomRef, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_MEMBER_TIMEOUT_MS, + contentLiveRoom, +} from "@/content/live/protocol"; + +/** One instance's members of one record room, as it tells the others. */ +export interface ContentLivePresenceSnapshot { + members: ContentLiveMember[]; + room: ContentLiveRoomRef; +} + +export interface ContentLiveLocalMember { + connection: TConnection; + lastSeen: number; + member: ContentLiveMember; + room: ContentLiveRoomRef; +} + +/** + * Who is in which record, on this instance and on the others. + * + * Local members are keyed by `clientId` (one per browser tab) and expire when + * their heartbeats stop. Members of other instances arrive as snapshots, one + * per instance and room, and expire when that instance stops refreshing them, + * so a crashed instance cannot leave ghosts behind. Every change is delivered + * to this instance's sockets as the union of both. + */ +export const createContentLivePresence = ({ + deliver, + now = Date.now, + publish, + timeoutMs = CONTENT_LIVE_MEMBER_TIMEOUT_MS, +}: { + /** Send the room's member list to the sockets of this instance. */ + deliver: (room: ContentLiveRoomRef, members: ContentLiveMember[]) => void; + now?: () => number; + /** Tell the other instances who is in a room here. */ + publish: (snapshot: ContentLivePresenceSnapshot) => void; + timeoutMs?: number; +}) => { + const refs = new Map(); + const local = new Map< + string, + Map> + >(); + const remote = new Map< + string, + Map + >(); + + const forget = (key: string): void => { + if (!local.has(key) && !remote.has(key)) refs.delete(key); + }; + + const localMembers = (key: string): ContentLiveMember[] => + [...(local.get(key)?.values() ?? [])].map(entry => entry.member); + + const membersOf = (room: ContentLiveRoomRef): ContentLiveMember[] => { + const key = contentLiveRoom(room); + const members = localMembers(key); + const seen = new Set(members.map(member => member.clientId)); + + for (const snapshot of remote.get(key)?.values() ?? []) { + for (const member of snapshot.members) { + if (seen.has(member.clientId)) continue; + seen.add(member.clientId); + members.push(member); + } + } + + return members; + }; + + const deliverRoom = (key: string): void => { + const room = refs.get(key); + if (room) deliver(room, membersOf(room)); + }; + + const announce = (key: string): void => { + const room = refs.get(key); + if (!room) return; + deliver(room, membersOf(room)); + publish({ members: localMembers(key), room }); + }; + + const removeLocal = ( + key: string, + clientId: string, + ): ContentLiveLocalMember | undefined => { + const members = local.get(key); + const entry = members?.get(clientId); + if (!members || !entry) return undefined; + + members.delete(clientId); + if (members.size === 0) local.delete(key); + announce(key); + forget(key); + + return entry; + }; + + return { + /** Apply another instance's snapshot. Returns whether that instance is new to the room. */ + applyRemote: ( + origin: string, + { members, room }: ContentLivePresenceSnapshot, + ): boolean => { + const key = contentLiveRoom(room); + const instances = + remote.get(key) ?? + new Map(); + const isNew = !instances.has(origin); + + if (members.length === 0) { + if (isNew) return false; + instances.delete(origin); + if (instances.size === 0) remote.delete(key); + } else { + refs.set(key, room); + instances.set(origin, { expiresAt: now() + timeoutMs, members }); + remote.set(key, instances); + } + + deliverRoom(key); + forget(key); + + return isNew && members.length > 0; + }, + /** Update the field and language a member is in. `false` if it is not here. */ + focus: ( + room: ContentLiveRoomRef, + clientId: string, + { field, locale }: Pick, + ): boolean => { + const key = contentLiveRoom(room); + const entry = local.get(key)?.get(clientId); + if (!entry) return false; + + entry.lastSeen = now(); + if (entry.member.field === field && entry.member.locale === locale) { + return true; + } + entry.member = { ...entry.member, field, locale }; + announce(key); + + return true; + }, + get: ( + room: ContentLiveRoomRef, + clientId: string, + ): ContentLiveLocalMember | undefined => + local.get(contentLiveRoom(room))?.get(clientId), + /** Whether `userId` still has a member (tab) in the room on this instance. */ + hasUser: (room: ContentLiveRoomRef, userId: number): boolean => + [...(local.get(contentLiveRoom(room))?.values() ?? [])].some( + entry => entry.member.userId === userId, + ), + /** Keep a member alive. `false` if it is not here. */ + heartbeat: (room: ContentLiveRoomRef, clientId: string): boolean => { + const entry = local.get(contentLiveRoom(room))?.get(clientId); + if (!entry) return false; + entry.lastSeen = now(); + + return true; + }, + /** Add or replace a member. Returns the room's members afterwards. */ + join: ({ + connection, + member, + room, + }: { + connection: TConnection; + member: ContentLiveMember; + room: ContentLiveRoomRef; + }): ContentLiveMember[] => { + const key = contentLiveRoom(room); + refs.set(key, room); + const members = + local.get(key) ?? + new Map>(); + local.set(key, members); + members.set(member.clientId, { + connection, + lastSeen: now(), + member, + room, + }); + announce(key); + + return membersOf(room); + }, + /** Every local member, for a socket that closed. */ + localMembers: (): ContentLiveLocalMember[] => + [...local.values()].flatMap(members => [...members.values()]), + membersOf, + /** + * Re-send this instance's snapshot of every room it has members in, or of + * one room only. + */ + refresh: (only?: ContentLiveRoomRef): void => { + const onlyKey = only ? contentLiveRoom(only) : undefined; + for (const [key, members] of local) { + const room = refs.get(key); + if (room && (onlyKey === undefined || onlyKey === key)) { + publish({ + members: [...members.values()].map(entry => entry.member), + room, + }); + } + } + }, + remove: ( + room: ContentLiveRoomRef, + clientId: string, + ): ContentLiveLocalMember | undefined => + removeLocal(contentLiveRoom(room), clientId), + /** + * Drop local members whose heartbeats stopped and other instances' + * snapshots that were not refreshed. Returns the local members removed. + */ + sweep: (): ContentLiveLocalMember[] => { + const at = now(); + const removed: ContentLiveLocalMember[] = []; + + for (const [key, members] of [...local]) { + for (const [clientId, entry] of [...members]) { + if (at - entry.lastSeen < timeoutMs) continue; + const gone = removeLocal(key, clientId); + if (gone) removed.push(gone); + } + } + + for (const [key, instances] of [...remote]) { + let changed = false; + for (const [origin, snapshot] of [...instances]) { + if (snapshot.expiresAt > at) continue; + instances.delete(origin); + changed = true; + } + if (instances.size === 0) remote.delete(key); + if (changed) { + deliverRoom(key); + forget(key); + } + } + + return removed; + }, + }; +}; + +export type ContentLivePresence = ReturnType< + typeof createContentLivePresence +>; diff --git a/packages/vitnode/src/content/server/live/server.ts b/packages/vitnode/src/content/server/live/server.ts new file mode 100644 index 0000000000..092e4bac67 --- /dev/null +++ b/packages/vitnode/src/content/server/live/server.ts @@ -0,0 +1,491 @@ +import type { Context } from "hono"; +import type { WSContext } from "hono/ws"; + +import { z } from "zod"; + +import type { + ContentLiveClientMessage, + ContentLiveErrorCode, + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_HEARTBEAT_MS, + contentLiveChannel, + contentLiveRoom, +} from "@/content/live/protocol"; +import { publishToInstances, realtime, wsRegistry } from "@/ws/registry"; + +import type { ContentLiveAuthorization } from "./auth"; +import type { ContentDocumentStore } from "./documents"; +import type { + ContentLiveLocalMember, + ContentLivePresenceSnapshot, +} from "./presence"; + +import { + authorizeContentLive, + createContentLiveAuthCache, + isContentLiveDocValid, +} from "./auth"; +import { createContentDocumentStore } from "./document-store"; +import { createContentDocuments } from "./documents"; +import { contentLiveUserLeft } from "./hooks"; +import { clientIdOf, contentLiveClientMessageSchema } from "./messages"; +import { createContentLivePresence } from "./presence"; + +/** The instance-to-instance topic presence snapshots travel on. */ +export const CONTENT_LIVE_PRESENCE_TOPIC = `${contentLiveChannel.id}:presence`; + +const DOC_ROOM_PREFIX = "content-doc:"; + +/** One socket, with the request context it was opened with. */ +export interface ContentLiveConnection { + c: Context; + ws: WSContext; +} + +type LocalMember = ContentLiveLocalMember; + +const snapshotSchema = z.object({ + members: z.array( + z.object({ + avatarColor: z.string().nullable(), + clientId: z.string(), + field: z.string().nullable(), + locale: z.string().nullable(), + name: z.string(), + nameCode: z.string().nullable(), + userId: z.number(), + }), + ), + room: z.object({ contentTypeId: z.string(), itemId: z.number() }), +}) satisfies z.ZodType; + +const resetSchema = z.object({ + room: z.object({ contentTypeId: z.string(), itemId: z.number() }), + type: z.literal("reset"), +}); + +const logError = (message: string, error: unknown): void => { + // eslint-disable-next-line no-console + console.error(message, error); +}; + +const sendTo = (ws: WSContext, data: ContentLiveServerMessage): void => { + try { + ws.send(JSON.stringify({ data, id: contentLiveChannel.id })); + } catch { + // The socket is closing; its close listener cleans up. + } +}; + +const roomOf = (message: ContentLiveClientMessage): ContentLiveRoomRef => + "room" in message + ? message.room + : { + contentTypeId: message.doc.contentTypeId, + itemId: message.doc.itemId, + }; + +/** + * The live editing socket of the Content Engine: authorization, presence and + * the collaborative documents, behind one `onMessage`. + * + * Every dependency with a side effect outside this instance (the registry, the + * database, Redis, the clock) is a parameter, so tests drive the real logic + * without a socket server. + */ +export const createContentLiveServer = ({ + authorize = authorizeContentLive, + authTtlMs, + createStore = createContentDocumentStore, + isDocValid = isContentLiveDocValid, + now = Date.now, + persistMs, + publish = snapshot => { + publishToInstances(CONTENT_LIVE_PRESENCE_TOPIC, snapshot); + }, + registry = wsRegistry, + relay = (room, message) => { + realtime.toRoom(room, contentLiveChannel, message); + }, + sweepMs = CONTENT_LIVE_HEARTBEAT_MS, + timeoutMs, + userLeft = contentLiveUserLeft, +}: { + authorize?: ( + c: Context, + room: ContentLiveRoomRef, + ) => Promise; + authTtlMs?: number; + createStore?: (c: Context) => ContentDocumentStore; + isDocValid?: typeof isContentLiveDocValid; + now?: () => number; + persistMs?: number; + /** Tell the other instances who is in a room here. */ + publish?: (snapshot: ContentLivePresenceSnapshot) => void; + registry?: Pick; + /** Deliver to a document room on every instance. */ + relay?: (room: string, message: ContentLiveServerMessage) => void; + sweepMs?: number; + timeoutMs?: number; + userLeft?: typeof contentLiveUserLeft; +} = {}) => { + const auth = createContentLiveAuthCache({ authorize, now, ttlMs: authTtlMs }); + const connections = new WeakMap(); + const queues = new WeakMap>(); + + const presence = createContentLivePresence({ + deliver: (room, members) => { + registry.toRoom(contentLiveRoom(room), contentLiveChannel.id, { + members, + room, + type: "presence", + } satisfies ContentLiveServerMessage); + }, + now, + publish, + timeoutMs, + }); + + const documents = createContentDocuments({ + join: ({ ws }, room, clientId) => { + registry.join(ws, room, clientId); + }, + leave: ({ ws }, room, clientId) => { + registry.leave(ws, room, clientId); + }, + persistMs, + relay, + send: ({ ws }, message) => { + sendTo(ws, message); + }, + }); + + const connectionOf = (c: Context, ws: WSContext): ContentLiveConnection => { + const existing = connections.get(ws); + if (existing) return existing; + const created = { c, ws }; + connections.set(ws, created); + + return created; + }; + + /** + * Everything that follows members leaving a record room, however they left. + * Called right after they were removed from presence, before anything is + * awaited, so two tabs of one user closing together report the user once. + */ + const afterLeft = async (entries: LocalMember[]): Promise => { + const gone = new Map(); + for (const entry of entries) { + const key = `${contentLiveRoom(entry.room)}|${entry.member.userId}`; + if (!presence.hasUser(entry.room, entry.member.userId)) { + gone.set(key, entry); + } + } + + await Promise.all( + entries.map(async ({ connection, member, room }) => { + registry.leave(connection.ws, contentLiveRoom(room), member.clientId); + await documents.closeClient(room, member.clientId); + }), + ); + await Promise.all( + [...gone.values()].map(async ({ connection, member, room }) => { + await userLeft({ c: connection.c, room, userId: member.userId }); + }), + ); + }; + + const removeMembers = async ( + members: { clientId: string; room: ContentLiveRoomRef }[], + ): Promise => { + const removed = members.flatMap( + ({ clientId, room }) => presence.remove(room, clientId) ?? [], + ); + await afterLeft(removed); + }; + + const removeMember = async ( + room: ContentLiveRoomRef, + clientId: string, + ): Promise => { + await removeMembers([{ clientId, room }]); + }; + + const isMember = ( + connection: ContentLiveConnection, + room: ContentLiveRoomRef, + clientId: string, + ): boolean => presence.get(room, clientId)?.connection === connection; + + let sweeper: ReturnType | undefined; + + const sweep = async (): Promise => { + const removed = presence.sweep(); + presence.refresh(); + await afterLeft(removed); + await documents.retrySeeding(); + }; + + const startSweeper = (): void => { + if (sweeper) return; + sweeper = setInterval(() => { + sweep().catch((error: unknown) => { + logError("Content live sweep error:", error); + }); + }, sweepMs); + sweeper.unref?.(); + }; + + const handleMessage = async ( + connection: ContentLiveConnection, + message: ContentLiveClientMessage, + ): Promise => { + const { c, ws } = connection; + const { clientId } = message; + const room = roomOf(message); + const fail = (code: ContentLiveErrorCode): void => { + sendTo(ws, { clientId, code, room, type: "error" }); + }; + + /** Authorize (cached); a member that lost access leaves the room. */ + const guard = async (): Promise => { + const result = await auth.authorize(c, ws, room); + if (!result.ok) { + if (isMember(connection, room, clientId)) { + await removeMember(room, clientId); + } + fail(result.code); + } + + return result; + }; + + if (message.type === "join") { + const result = await guard(); + if (!result.ok) return; + + const previous = presence.get(room, clientId); + if (previous && previous.connection !== connection) { + // Presence lists every `clientId`, so one cannot be taken over by + // somebody else; the same person's tab may move to a new socket. + if (previous.member.userId !== result.user.id) { + fail("FORBIDDEN"); + + return; + } + registry.leave(previous.connection.ws, contentLiveRoom(room), clientId); + } + registry.join(ws, contentLiveRoom(room), clientId); + const members = presence.join({ + connection, + member: { + avatarColor: result.user.avatarColor, + clientId, + field: previous?.member.field ?? null, + locale: message.locale, + name: result.user.name, + nameCode: result.user.nameCode, + userId: result.user.id, + }, + room, + }); + sendTo(ws, { clientId, members, room, type: "joined" }); + startSweeper(); + + return; + } + + if (message.type === "leave") { + if (isMember(connection, room, clientId)) { + await removeMember(room, clientId); + } + + return; + } + + if (!isMember(connection, room, clientId)) { + fail("NOT_JOINED"); + + return; + } + + const result = await guard(); + if (!result.ok) return; + + switch (message.type) { + case "doc:awareness": + case "doc:update": { + const args = { + clientId, + connection, + doc: message.doc, + update: message.update, + }; + const outcome = + message.type === "doc:update" + ? documents.update({ ...args, userId: result.user.id }) + : documents.awareness(args); + if (outcome !== "ok") fail(outcome); + + return; + } + case "doc:close": { + await documents.close({ clientId, connection, doc: message.doc }); + + return; + } + case "doc:open": { + if (!(await isDocValid(c, message.doc))) { + fail("NOT_FOUND"); + + return; + } + const outcome = await documents.open({ + baseVersion: result.version, + clientId, + connection, + doc: message.doc, + stateVector: message.stateVector, + store: createStore(c), + userId: result.user.id, + }); + if (outcome !== "ok") fail(outcome); + + return; + } + case "focus": { + presence.focus(room, clientId, { + field: message.field, + locale: message.locale, + }); + + return; + } + case "heartbeat": { + presence.heartbeat(room, clientId); + + return; + } + } + }; + + return { + documents, + /** The socket's `onMessage`. Messages of one socket run one at a time. */ + handle: async ({ + c, + data, + ws, + }: { + c: Context; + data: unknown; + ws: WSContext; + }): Promise => { + const parsed = contentLiveClientMessageSchema.safeParse(data); + if (!parsed.success) { + sendTo(ws, { + clientId: clientIdOf(data), + code: "INVALID_MESSAGE", + type: "error", + }); + + return; + } + + const connection = connectionOf(c, ws); + // A join must be done before the focus sent right after it is read, and + // one tab's Yjs updates should reach the room in the order it sent them. + const previous = queues.get(ws) ?? Promise.resolve(); + const current = previous.then(async () => { + await handleMessage(connection, parsed.data); + }); + const settled = current.catch((error: unknown) => { + logError("Content live message error:", error); + }); + queues.set(ws, settled); + await settled; + }, + /** Registry close listener: everything the closed socket had joined. */ + onConnectionClose: ( + ws: WSContext, + left: { memberId: string; room: string }[], + ): void => { + const connection = connections.get(ws); + if (!connection) return; + + const work: Promise[] = []; + for (const { memberId, room } of left) { + if (room.startsWith(DOC_ROOM_PREFIX)) { + work.push(documents.closeRoom(room, memberId, connection)); + } + } + work.push( + removeMembers( + presence + .localMembers() + .filter(entry => entry.connection === connection) + .map(({ member, room }) => ({ clientId: member.clientId, room })), + ), + ); + + void Promise.all(work).catch((error: unknown) => { + logError("Content live close error:", error); + }); + }, + /** Another instance's presence snapshot. */ + onInstanceMessage: ({ + data, + origin, + topic, + }: { + data: unknown; + origin: string; + topic: string; + }): void => { + if (topic !== CONTENT_LIVE_PRESENCE_TOPIC) return; + const parsed = snapshotSchema.safeParse(data); + if (!parsed.success) return; + + // A newcomer learns about this instance's members right away rather + // than at the next refresh. + if (presence.applyRemote(origin, parsed.data)) { + presence.refresh(parsed.data.room); + } + }, + /** A room message another instance relayed: keep loaded documents in step. */ + onRemoteRoomMessage: (room: string, id: string, data: unknown): void => { + if (id !== contentLiveChannel.id) return; + if (room.startsWith(DOC_ROOM_PREFIX)) { + documents.applyRemote(room, data); + + return; + } + const reset = resetSchema.safeParse(data); + if (reset.success) documents.resetRecord(reset.data.room); + }, + /** `onContentLiveReset` listener: drop the record's documents. */ + onReset: async ({ + c, + room, + }: { + c: Context; + room: ContentLiveRoomRef; + }): Promise => { + documents.resetRecord(room); + await createStore(c).deleteRecord(room); + }, + presence, + stop: (): void => { + clearInterval(sweeper); + sweeper = undefined; + }, + sweep, + }; +}; + +export type ContentLiveServer = ReturnType; diff --git a/packages/vitnode/src/content/server/live/socket.ts b/packages/vitnode/src/content/server/live/socket.ts new file mode 100644 index 0000000000..a30cff04fd --- /dev/null +++ b/packages/vitnode/src/content/server/live/socket.ts @@ -0,0 +1,30 @@ +import { buildWebSocket } from "@/api/lib/websocket"; +import { + onInstanceMessage, + onRemoteRoomMessage, + wsRegistry, +} from "@/ws/registry"; + +import { onContentLiveReset } from "./hooks"; +import { createContentLiveServer } from "./server"; + +/** The live editing server of this process. */ +export const contentLiveServer = createContentLiveServer(); + +wsRegistry.onConnectionClose(contentLiveServer.onConnectionClose); +onRemoteRoomMessage(contentLiveServer.onRemoteRoomMessage); +onInstanceMessage(contentLiveServer.onInstanceMessage); +onContentLiveReset(contentLiveServer.onReset); + +/** + * Registered on core's `content` module with the id `live`, so its public id is + * `contentLiveChannel.id` (`@vitnode/core_content_live`). + */ +export const contentLiveWebSocket = buildWebSocket({ + description: + "Content Engine live editing: presence and collaborative rich text documents.", + id: "live", + onMessage: async ({ c, data, ws }) => { + await contentLiveServer.handle({ c, data, ws }); + }, +}); diff --git a/packages/vitnode/src/database/content-documents.ts b/packages/vitnode/src/database/content-documents.ts new file mode 100644 index 0000000000..4c32209be4 --- /dev/null +++ b/packages/vitnode/src/database/content-documents.ts @@ -0,0 +1,60 @@ +import { camelCase, customType, index, uniqueIndex } from "drizzle-orm/pg-core"; + +import { core_users } from "./users"; + +/** Raw bytes. Drizzle ships no `bytea` column, so it is declared once here. */ +const bytea = customType<{ data: Uint8Array; driverData: Buffer }>({ + dataType() { + return "bytea"; + }, + fromDriver(value) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + }, + toDriver(value) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + }, +}); + +/** + * The collaborative state (a Yjs update) of one rich text field of one record + * in one language: the working copy editors type into together. The record + * itself only changes on Save. + */ +export const core_content_documents = camelCase.table.withRLS( + "core_content_documents", + t => ({ + id: t.serial().primaryKey(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + field: t.varchar({ length: 100 }).notNull(), + /** The locale, or `""` for a field that is not localized. */ + language: t.varchar({ length: 35 }).notNull().default(""), + /** + * The merged Yjs state. Empty while a seeder is filling a new document, so + * every instance can tell "being seeded" from "never opened". + */ + state: bytea().notNull(), + /** The record's version when the document was last written. */ + baseVersion: t.integer(), + updatedAt: t.timestamp().notNull().defaultNow(), + updatedBy: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + }), + t => [ + uniqueIndex("core_content_documents_doc_unique").on( + t.contentTypeId, + t.itemId, + t.field, + t.language, + ), + // The stale-document cleanup scans by age. + index("core_content_documents_updated_at_idx").on(t.updatedAt), + // Postgres does not index the child side of a foreign key on its own, and + // `ON DELETE SET NULL` scans it on every user deletion. + index("core_content_documents_updated_by_idx").on(t.updatedBy), + ], +); + +export type ContentDocumentRow = typeof core_content_documents.$inferSelect; diff --git a/packages/vitnode/src/tests/content-live.ts b/packages/vitnode/src/tests/content-live.ts new file mode 100644 index 0000000000..ab3c45a466 --- /dev/null +++ b/packages/vitnode/src/tests/content-live.ts @@ -0,0 +1,172 @@ +import type { Context } from "hono"; +import type { WSContext } from "hono/ws"; + +import type { + ContentLiveClientMessage, + ContentLiveDocRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; +import type { ContentLiveUser } from "@/content/server/live/auth"; +import type { ContentDocumentStore } from "@/content/server/live/documents"; + +import { createContentLiveServer } from "@/content/server/live/server"; +import { wsRegistry } from "@/ws/registry"; + +type ServerOptions = NonNullable[0]>; + +/** An in-memory document store that counts what it was asked to do. */ +export const createFakeDocumentStore = () => { + const rows = new Map(); + const claims = new Set(); + const keyOf = (doc: ContentLiveDocRef) => + `${doc.contentTypeId}:${doc.itemId}:${doc.field}:${doc.locale ?? ""}`; + const saves: { doc: ContentLiveDocRef; userId: null | number }[] = []; + + const store: ContentDocumentStore = { + claimSeed: async doc => { + const key = keyOf(doc); + if (rows.has(key) || claims.has(key)) return Promise.resolve(false); + claims.add(key); + + return Promise.resolve(true); + }, + deleteRecord: async room => { + const prefix = `${room.contentTypeId}:${room.itemId}:`; + for (const key of [...rows.keys(), ...claims]) { + if (!key.startsWith(prefix)) continue; + rows.delete(key); + claims.delete(key); + } + + return Promise.resolve(); + }, + load: async doc => Promise.resolve(rows.get(keyOf(doc)) ?? null), + releaseSeed: async doc => { + claims.delete(keyOf(doc)); + + return Promise.resolve(); + }, + save: async (doc, state, { userId }) => { + rows.set(keyOf(doc), state); + claims.delete(keyOf(doc)); + saves.push({ doc, userId }); + + return Promise.resolve(); + }, + }; + + return { + claims, + has: (doc: ContentLiveDocRef) => rows.has(keyOf(doc)), + saves, + store, + }; +}; + +export interface FakeLiveSocket { + c: Context; + /** Every live message this socket received, oldest first. */ + received: () => ContentLiveServerMessage[]; + ws: WSContext; +} + +/** + * A live server on fake sockets: each socket has its own `c`, and so its own + * editor. Authorization is granted per socket, which the tests flip. + */ +export const createLiveHarness = (options: Partial = {}) => { + const users = new Map(); + const denied = new Set(); + const sockets: WSContext[] = []; + const store = createFakeDocumentStore(); + const userLeft: { room: unknown; userId: number }[] = []; + + const server = createContentLiveServer({ + authorize: async c => { + const user = users.get(c); + if (!user || denied.has(c)) { + return Promise.resolve({ + code: "FORBIDDEN" as const, + ok: false as const, + }); + } + + return Promise.resolve({ ok: true as const, user, version: 3 }); + }, + createStore: () => store.store, + isDocValid: async (_c, doc) => Promise.resolve(doc.field === "content"), + userLeft: async ({ room, userId }) => { + userLeft.push({ room, userId }); + + return Promise.resolve(); + }, + ...options, + }); + const usesRealRegistry = !options.registry; + const stopClose = usesRealRegistry + ? wsRegistry.onConnectionClose(server.onConnectionClose) + : () => {}; + + const socket = (user: Partial & { id: number }) => { + const sent: { data: ContentLiveServerMessage; id: string }[] = []; + const ws = { + send: (raw: string) => { + sent.push( + JSON.parse(raw) as { data: ContentLiveServerMessage; id: string }, + ); + }, + } as unknown as WSContext; + const c = {} as Context; + users.set(c, { + avatarColor: null, + name: `user-${user.id}`, + nameCode: null, + ...user, + }); + if (usesRealRegistry) wsRegistry.add(ws, user.id); + sockets.push(ws); + + return { + c, + received: () => sent.map(message => message.data), + ws, + } satisfies FakeLiveSocket; + }; + + const send = async ( + target: FakeLiveSocket, + message: ContentLiveClientMessage | Record, + ): Promise => { + await server.handle({ c: target.c, data: message, ws: target.ws }); + }; + + return { + deny: (target: FakeLiveSocket) => denied.add(target.c), + /** Close a socket the way the registry does. */ + disconnect: (target: FakeLiveSocket) => { + wsRegistry.remove(target.ws); + }, + send, + server, + socket, + stop: () => { + server.stop(); + stopClose(); + sockets.splice(0).forEach(ws => wsRegistry.remove(ws)); + }, + store, + userLeft, + }; +}; + +// An intersection rather than `Extract`: `doc:update` and `doc:awareness` share +// one union member, which `Extract` would drop. +type MessageOfType = ContentLiveServerMessage & { type: TType }; + +export const ofType = ( + messages: ContentLiveServerMessage[], + type: TType, +): MessageOfType[] => + messages.filter( + (message): message is MessageOfType => message.type === type, + ); diff --git a/packages/vitnode/src/ws/instances.test.ts b/packages/vitnode/src/ws/instances.test.ts new file mode 100644 index 0000000000..0772f3a85d --- /dev/null +++ b/packages/vitnode/src/ws/instances.test.ts @@ -0,0 +1,162 @@ +import type { WSContext } from "hono/ws"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { CacheClient } from "@/api/lib/cache"; + +import { + getRealtimeInstanceId, + initRealtimePubSub, + onInstanceMessage, + onRemoteRoomMessage, + publishToInstances, + wsRegistry, +} from "./registry"; + +/** A pub/sub client whose subscriber callback the test can call itself. */ +const bus = () => { + const published: string[] = []; + let deliver: ((raw: string) => void) | undefined; + + const client = { + duplicate: () => { + const subscriber = { + connect: async () => Promise.resolve(), + on: () => subscriber, + subscribe: async ( + _channel: string, + listener: (raw: string) => void, + ) => { + deliver = listener; + + return Promise.resolve(); + }, + }; + + return subscriber; + }, + publish: async (_channel: string, raw: string) => { + published.push(raw); + + return Promise.resolve(1); + }, + }; + + return { + client: client as unknown as CacheClient, + /** Hand a message to this instance as if another instance published it. */ + fromOtherInstance: (message: Record) => { + deliver?.(JSON.stringify({ origin: "other-instance", ...message })); + }, + published: () => + published.map(raw => JSON.parse(raw) as Record), + }; +}; + +const pubsub = bus(); +initRealtimePubSub(pubsub.client); +await Promise.resolve(); +await Promise.resolve(); + +const sockets: WSContext[] = []; + +const socket = () => { + const sent: unknown[] = []; + const ws = { + send: (raw: string) => { + sent.push(JSON.parse(raw)); + }, + } as unknown as WSContext; + wsRegistry.add(ws, 1); + sockets.push(ws); + + return { sent, ws }; +}; + +afterEach(() => { + sockets.splice(0).forEach(ws => wsRegistry.remove(ws)); +}); + +describe("instance messages", () => { + it("are published with this instance as the origin", () => { + publishToInstances("presence", { n: 1 }); + + expect(pubsub.published().at(-1)).toEqual({ + data: { n: 1 }, + id: "presence", + origin: getRealtimeInstanceId(), + type: "instance", + }); + }); + + it("reach instance listeners of the other instances, never a socket", () => { + const member = socket(); + wsRegistry.join(member.ws, "room", "tab-a"); + const listener = vi.fn(); + const rooms = vi.fn(); + const stop = onInstanceMessage(listener); + const stopRooms = onRemoteRoomMessage(rooms); + + pubsub.fromOtherInstance({ + data: { n: 2 }, + id: "presence", + type: "instance", + }); + stop(); + stopRooms(); + + expect(listener).toHaveBeenCalledWith({ + data: { n: 2 }, + origin: "other-instance", + topic: "presence", + }); + expect(rooms).not.toHaveBeenCalled(); + expect(member.sent).toEqual([]); + }); + + it("are ignored when they come back from this instance", () => { + const listener = vi.fn(); + const stop = onInstanceMessage(listener); + + pubsub.fromOtherInstance({ + data: {}, + id: "presence", + origin: getRealtimeInstanceId(), + type: "instance", + }); + stop(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("room messages from another instance", () => { + it("are observed first, then delivered to the room's local members", () => { + const member = socket(); + const outside = socket(); + wsRegistry.join(member.ws, "content:test.note:1", "tab-a"); + const observed: unknown[] = []; + const stop = onRemoteRoomMessage((room, id, data) => { + observed.push({ data, id, room, sentBefore: member.sent.length }); + }); + + pubsub.fromOtherInstance({ + data: { type: "reset" }, + id: "live", + room: "content:test.note:1", + type: "room", + }); + stop(); + + expect(observed).toEqual([ + { + data: { type: "reset" }, + id: "live", + room: "content:test.note:1", + sentBefore: 0, + }, + ]); + expect(member.sent).toEqual([{ data: { type: "reset" }, id: "live" }]); + expect(outside.sent).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/ws/registry.ts b/packages/vitnode/src/ws/registry.ts index 5c9417d2e1..172cde1841 100644 --- a/packages/vitnode/src/ws/registry.ts +++ b/packages/vitnode/src/ws/registry.ts @@ -150,7 +150,7 @@ interface RealtimePubSubMessage { id: string; origin: string; room?: string; - type: "broadcast" | "room" | "sendToUser"; + type: "broadcast" | "instance" | "room" | "sendToUser"; userId?: number; } @@ -186,6 +186,33 @@ export const onRemoteRoomMessage = ( }; }; +type InstanceMessageListener = (message: { + data: unknown; + /** The id of the instance that published it. */ + origin: string; + topic: string; +}) => void; + +const instanceListeners = new Set(); + +/** + * Observe what other instances published with {@link publishToInstances}. + * Those messages never reach a socket: they carry state an instance keeps in + * memory about its own sockets (who is in a room) for the other instances. + */ +export const onInstanceMessage = ( + listener: InstanceMessageListener, +): (() => void) => { + instanceListeners.add(listener); + + return () => { + instanceListeners.delete(listener); + }; +}; + +/** This process' id, as the other instances see it in `onInstanceMessage`. */ +export const getRealtimeInstanceId = (): string => instanceId; + const publish = (message: Omit): void => { if (!publisher) return; @@ -223,6 +250,14 @@ export const initRealtimePubSub = (client: CacheClient | null): void => { const message = JSON.parse(raw) as RealtimePubSubMessage; // Skip our own echo - the publisher already delivered locally. if (message.origin === instanceId) return; + if (message.type === "instance") { + const { data, id: topic, origin } = message; + instanceListeners.forEach(listener => { + listener({ data, origin, topic }); + }); + + return; + } if (message.type === "room" && typeof message.room === "string") { const { data, id, room } = message; roomListeners.forEach(listener => listener(room, id, data)); @@ -238,6 +273,15 @@ export const initRealtimePubSub = (client: CacheClient | null): void => { }); }; +/** + * Tell the other instances something about this one, under `topic`. Nothing is + * delivered to a socket, and without Redis nothing happens at all: a single + * instance has nobody to tell. + */ +export const publishToInstances = (topic: string, data: unknown): void => { + publish({ data, id: topic, type: "instance" }); +}; + export interface VitNodeRealtime { broadcast: ( channel: VitNodeWSChannel, From ee238fb3a73d1bf6b56eb9fc8d96292a5033b39a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:50:19 +0000 Subject: [PATCH 04/25] =?UTF-8?q?feat(content):=20=E2=9C=A8=20add=20field?= =?UTF-8?q?=20locks,=20shared=20drafts=20and=20autosave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editorial content types gain the HTTP half of live editing: per-field, per-language lock leases (atomic upsert, 409 naming the holder) and one shared draft per record and language that autosave merges into, never touching the record. Every change is broadcast to the record's room, a reset (revision restore, delete) throws the working state away, and a user leaving the room gives their locks back. The AdminCP form joins the room for an existing record, opens on the draft, locks a field while it has focus, shows "Anna is editing" on fields someone else holds, autosaves the fields it holds and reports "Saving…", "Draft saved at …" and "Unsaved changes". Without a socket it polls the locks and the draft instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- packages/vitnode/src/content/admin/spec.ts | 73 +++ packages/vitnode/src/content/live/http.ts | 95 ++++ packages/vitnode/src/content/schemas.ts | 12 + .../src/content/server/live-routes.test.ts | 521 ++++++++++++++++++ .../vitnode/src/content/server/live-routes.ts | 363 ++++++++++++ .../vitnode/src/content/server/live-store.ts | 410 ++++++++++++++ .../content/server/permission-matrix.test.ts | 6 + packages/vitnode/src/content/server/routes.ts | 14 + .../src/content/server/translation-routes.ts | 6 + .../vitnode/src/database/content-drafts.ts | 89 +++ packages/vitnode/src/locales/en.json | 20 + .../tanstack/admin/content/form/transport.ts | 23 + packages/vitnode/src/tests/memory-db.ts | 35 +- .../views/content/actions/content-form.tsx | 104 ++-- .../views/admin/views/content/form/index.ts | 10 + .../views/content/form/layout-primitives.tsx | 14 +- .../admin/views/content/form/transport.tsx | 44 ++ .../src/views/admin/views/content/live/api.ts | 122 ++++ .../views/admin/views/content/live/context.ts | 42 ++ .../admin/views/content/live/field.test.tsx | 231 ++++++++ .../views/admin/views/content/live/field.tsx | 322 +++++++++++ .../views/admin/views/content/live/root.tsx | 294 ++++++++++ .../views/admin/views/content/live/status.tsx | 41 ++ .../views/content/live/use-session.test.tsx | 335 +++++++++++ .../admin/views/content/live/use-session.ts | 350 ++++++++++++ .../admin/views/content/live/values.test.ts | 90 +++ .../views/admin/views/content/live/values.ts | 136 +++++ 27 files changed, 3767 insertions(+), 35 deletions(-) create mode 100644 packages/vitnode/src/content/live/http.ts create mode 100644 packages/vitnode/src/content/server/live-routes.test.ts create mode 100644 packages/vitnode/src/content/server/live-routes.ts create mode 100644 packages/vitnode/src/content/server/live-store.ts create mode 100644 packages/vitnode/src/database/content-drafts.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/api.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/context.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/field.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/field.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/root.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/status.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/use-session.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/values.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/values.ts diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 289f836e17..5a5d4687ef 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -80,6 +80,11 @@ export interface ContentFormSpec { contentTypeId: string; defaultLocale: null | string; + /** + * Present on an editorial content type: its records have a version, which is + * what live editing (field locks, the shared draft, autosave) measures against. + */ + editorial?: true; fields: ContentFormFieldSpec[]; permissionModule: string; @@ -283,6 +288,7 @@ export const buildContentFormSpec = ({ defaultLocale: definition.localization.enabled ? definition.localization.defaultLocale : null, + ...(definition.editorial.enabled ? { editorial: true as const } : {}), permissionModule: definition.permissionModule, pluginId, titleField: definition.admin.titleField, @@ -772,3 +778,70 @@ export const buildFormSchemaFromSpec = ( }), ), ); + +/** + * One field's form value as the API takes it - what live editing autosaves + * into the shared draft. `undefined` while the value is not writable yet (it + * fails the field's own rules, or it is empty where empty means "unset"), so a + * half-typed value is simply not autosaved. + * + * `locale` picks the language of a localized field, which holds them all. + */ +export const contentFormFieldToApi = ( + spec: ContentFormSpec, + fieldSpec: ContentFormFieldSpec, + value: unknown, + locale: null | string, +): undefined | { value: unknown } => { + if (fieldSpec.localized === true) { + if (locale === null) return undefined; + + const converted = localizedValueForApi( + fieldSpec, + getLangValue(value as MultiLangValue | undefined, locale), + ); + + return converted === undefined ? undefined : { value: converted }; + } + + const schema = buildFormSchemaFromSpec({ ...spec, fields: [fieldSpec] }) + .shape[fieldSpec.name]; + const parsed = schema ? z.safeParse(schema, value) : undefined; + if (!parsed?.success || parsed.data === undefined) return undefined; + + return { + value: contentFormValuesToPayload(spec, { [fieldSpec.name]: parsed.data })[ + fieldSpec.name + ], + }; +}; + +/** + * The reverse of {@link contentFormFieldToApi}: an API value written into the + * form value a field holds now. A localized field changes only `locale`; a + * single reference keeps the label it already shows for the same identifier. + */ +export const contentApiValueToFormField = ( + fieldSpec: ContentFormFieldSpec, + value: unknown, + current: unknown, + locale: null | string, +): unknown => { + if (fieldSpec.localized === true) { + if (locale === null) return current; + + return upsertLangValue( + current as MultiLangValue | undefined, + locale, + typeof value === "string" ? value : "", + ); + } + + const shown = current as null | Partial | undefined; + const label = + shown?.value !== undefined && shown.value === String(value) + ? (shown.label ?? null) + : null; + + return toInitialValue(fieldSpec, value, { [fieldSpec.name]: label }); +}; diff --git a/packages/vitnode/src/content/live/http.ts b/packages/vitnode/src/content/live/http.ts new file mode 100644 index 0000000000..3ec2fc5648 --- /dev/null +++ b/packages/vitnode/src/content/live/http.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +import { CONTENT_LOCALE_MAX_LENGTH } from "../const"; + +/** + * The HTTP half of live editing: field locks and the shared draft. The socket + * carries the same facts as `locks` and `draft` messages, and a tab without a + * socket polls these routes instead. + */ + +export const CONTENT_LIVE_CODES = { + draftInvalid: "CONTENT_DRAFT_INVALID", + draftScope: "CONTENT_DRAFT_WRONG_SCOPE", + locked: "CONTENT_FIELD_LOCKED", + notLocked: "CONTENT_FIELD_NOT_LOCKED", +} as const; + +export const CONTENT_FIELD_LOCK_ACTIONS = [ + "acquire", + "renew", + "release", +] as const; + +export type ContentFieldLockAction = + (typeof CONTENT_FIELD_LOCK_ACTIONS)[number]; + +const zodLocale = z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).nullable(); + +export const zodContentFieldLock = z.object({ + expiresAt: z.string(), + field: z.string(), + locale: z.string().nullable(), + user: z.object({ id: z.number(), name: z.string() }), +}); + +export const zodContentFieldLockList = z.object({ + locks: z.array(zodContentFieldLock), +}); + +export const zodContentFieldLockRequest = z.strictObject({ + action: z.enum(CONTENT_FIELD_LOCK_ACTIONS), + field: z.string().min(1).max(100), + /** `null` for a shared field, a language code for a localized one. */ + locale: zodLocale, +}); + +export const zodContentFieldLockResponse = z.object({ + /** `null` once released. */ + lock: zodContentFieldLock.nullable(), +}); + +export const zodContentFieldLocked = z.object({ + code: z.literal(CONTENT_LIVE_CODES.locked), + /** Who holds it, or `null` when a renew found no lock of the caller's. */ + lock: zodContentFieldLock.nullable(), +}); + +export const zodContentDraft = z.object({ + baseVersion: z.number(), + updatedAt: z.string(), + updatedBy: z.object({ id: z.number(), name: z.string() }).nullable(), + values: z.record(z.string(), z.unknown()), +}); + +export type ContentDraft = z.infer; + +export const zodContentDrafts = z.object({ + shared: zodContentDraft.nullable(), + translations: z.record(z.string(), zodContentDraft), +}); + +export type ContentDrafts = z.infer; + +export const zodContentDraftWrite = z.strictObject({ + locale: zodLocale, + values: z + .record(z.string(), z.unknown()) + .refine(values => Object.keys(values).length > 0, { + message: "Provide at least one field.", + }), +}); + +export const zodContentDraftWritten = z.object({ updatedAt: z.string() }); + +/** Fields a draft write was refused for, and why. */ +export const zodContentDraftRejection = z.object({ + code: z.enum([ + CONTENT_LIVE_CODES.draftInvalid, + CONTENT_LIVE_CODES.draftScope, + CONTENT_LIVE_CODES.notLocked, + ]), + fields: z.array(z.string()), +}); + +export type ContentDraftRejection = z.infer; diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index ea886c4b47..ffac3c263e 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -431,6 +431,18 @@ const updateShape = ( }), ); +/** + * One field's value schema exactly as the `update` route validates it, for a + * write that carries a few fields at a time (the live editing draft). + */ +export const contentFieldUpdateSchema = ( + fields: ContentFieldMap, + name: string, +): undefined | z.ZodType => + fields[name] === undefined + ? undefined + : (updateShape(fields, [name])[name] as z.ZodType); + const filterShape = (fields: ContentFieldMap): z.ZodRawShape => Object.fromEntries( Object.entries(fields) diff --git a/packages/vitnode/src/content/server/live-routes.test.ts b/packages/vitnode/src/content/server/live-routes.test.ts new file mode 100644 index 0000000000..a4398aaefd --- /dev/null +++ b/packages/vitnode/src/content/server/live-routes.test.ts @@ -0,0 +1,521 @@ +// @vitest-environment node +import type { Context } from "hono"; +import type { MockInstance } from "vitest"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { + core_content_drafts, + core_content_field_locks, +} from "@/database/content-drafts"; +import { core_languages } from "@/database/languages"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { + testCategoryContentType, + testEditorialNoteContentType, + testLocalizedGuideContentType, +} from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; +import { realtime } from "@/ws/registry"; + +import { CONTENT_PERMISSIONS } from "../const"; +import { CONTENT_FIELD_LOCK_LEASE_MS } from "../live/protocol"; +import { cleanupStaleContentDrafts } from "./live-store"; +import { contentLiveUserLeft, resetContentLiveRecord } from "./live/hooks"; +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; + +const PLUGIN_ID = "@vitnode/example"; +const guides = createContentModel(testLocalizedGuideContentType); +const notes = createContentModel(testEditorialNoteContentType); + +const ANNA = 1; +const BEN = 2; +const VIEWER = 3; +const ROOM = { contentTypeId: guides.definition.id, itemId: 7 }; + +const users = [ + { id: ANNA, name: "Anna" }, + { id: BEN, name: "Ben" }, + { id: VIEWER, name: "Vic" }, +]; + +const viewOnly = (): PermissionsStaffArgs[] => [ + { + module: guides.definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: PLUGIN_ID, + }, +]; + +const harness = async ({ core }: { core?: Record } = {}) => { + const memory = createMemoryDb([ + [core_users, users], + [ + core_languages, + [ + { code: "en", default: true, id: 1 }, + { code: "pl", default: false, id: 2 }, + ], + ], + ]); + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: ANNA, + }); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: BEN, + }); + await grantStaffPermissions(cache, { + permissions: viewOnly(), + userId: VIEWER, + }); + + vi.spyOn(guides, "service").mockReturnValue({ + findRowById: async (id: number) => + await Promise.resolve(id === 7 ? { id, version: 3 } : null), + } as never); + vi.spyOn(guides, "translationService", "get").mockReturnValue( + () => + ({ + findByLocale: async (_id: number, locale: string) => + await Promise.resolve(locale === "pl" ? { version: 5 } : null), + }) as never, + ); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const id = Number(c.req.header("x-user") ?? ANNA); + c.set("admin", { + user: users.find(user => user.id === id), + } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + if (core) c.set("core", core as never); + await next(); + }); + for (const { handler, route } of buildContentRoutes(guides, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + const send = async ( + method: string, + path: string, + { body, user = ANNA }: { body?: unknown; user?: number } = {}, + ) => + await app.request(path, { + method, + headers: { + "content-type": "application/json", + "x-user": String(user), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + const lock = async ( + action: "acquire" | "release" | "renew", + { + field = "title", + locale = "pl", + user = ANNA, + }: { field?: string; locale?: null | string; user?: number } = {}, + ) => + await send("POST", "/7/locks", { + body: { action, field, locale }, + user, + }); + + const context = { + get: (key: string) => { + if (key === "db") return memory.db; + if (key === "core") return core; + + return undefined; + }, + } as unknown as Context; + + return { context, lock, memory, send }; +}; + +let toRoom: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-08T12:00:00Z")); + toRoom = vi.spyOn(realtime, "toRoom").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const broadcasts = (type: string) => + toRoom.mock.calls + .map(([, , data]) => data as { type: string }) + .filter(message => message.type === type); + +describe("field locks", () => { + it("gives a free field to the first editor and tells the room", async () => { + const { lock } = await harness(); + + const response = await lock("acquire"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + lock: { field: "title", locale: "pl", user: { id: ANNA, name: "Anna" } }, + }); + expect(toRoom).toHaveBeenCalledWith( + "content:test.localized-guide:7", + expect.anything(), + expect.objectContaining({ + locks: [expect.objectContaining({ field: "title", locale: "pl" })], + room: ROOM, + type: "locks", + }), + ); + }); + + it("refuses a held field with a 409 naming the holder", async () => { + const { lock } = await harness(); + await lock("acquire"); + + const response = await lock("acquire", { user: BEN }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "CONTENT_FIELD_LOCKED", + lock: { user: { id: ANNA, name: "Anna" } }, + }); + }); + + it("locks the same field separately in each language", async () => { + const { lock } = await harness(); + await lock("acquire", { locale: "pl" }); + + const response = await lock("acquire", { locale: "en", user: BEN }); + + expect(response.status).toBe(200); + }); + + it("renews only the holder's lease", async () => { + const { lock } = await harness(); + await lock("acquire"); + vi.setSystemTime(new Date("2026-10-08T12:00:30Z")); + + const renewed = await lock("renew"); + const stolen = await lock("renew", { user: BEN }); + + expect(renewed.status).toBe(200); + const body = (await renewed.json()) as { lock: { expiresAt: string } }; + expect(body.lock.expiresAt).toBe( + new Date( + Date.parse("2026-10-08T12:00:30Z") + CONTENT_FIELD_LOCK_LEASE_MS, + ).toISOString(), + ); + expect(stolen.status).toBe(409); + }); + + it("lets someone else take an expired lease", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + vi.setSystemTime( + new Date( + Date.parse("2026-10-08T12:00:01Z") + CONTENT_FIELD_LOCK_LEASE_MS, + ), + ); + + const response = await lock("acquire", { user: BEN }); + const list = await send("GET", "/7/locks"); + + expect(response.status).toBe(200); + await expect(list.json()).resolves.toMatchObject({ + locks: [{ field: "title", user: { name: "Ben" } }], + }); + }); + + it("lists only unexpired locks", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + vi.setSystemTime( + new Date( + Date.parse("2026-10-08T12:00:01Z") + CONTENT_FIELD_LOCK_LEASE_MS, + ), + ); + + const response = await send("GET", "/7/locks"); + + await expect(response.json()).resolves.toEqual({ locks: [] }); + }); + + it("releases only the holder's lock, idempotently", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + + const byOther = await lock("release", { user: BEN }); + const held = await send("GET", "/7/locks"); + const byHolder = await lock("release"); + const again = await lock("release"); + const after = await send("GET", "/7/locks"); + + expect(byOther.status).toBe(200); + await expect(held.json()).resolves.toMatchObject({ + locks: [{ user: { id: ANNA } }], + }); + expect(byHolder.status).toBe(200); + expect(again.status).toBe(200); + await expect(after.json()).resolves.toEqual({ locks: [] }); + expect(broadcasts("locks")).toHaveLength(2); + }); + + it.each([ + ["a localized field without a locale", { field: "title", locale: null }], + ["a shared field with a locale", { field: "featured", locale: "pl" }], + ["an unknown locale", { field: "title", locale: "xx" }], + ["an unknown field", { field: "nope", locale: null }], + ])("refuses %s", async (_name, target) => { + const { lock } = await harness(); + + const response = await lock("acquire", target); + + expect(response.status).toBe(400); + }); + + it("answers 404 for a record that is not there", async () => { + const { send } = await harness(); + + const response = await send("POST", "/8/locks", { + body: { action: "acquire", field: "featured", locale: null }, + }); + + expect(response.status).toBe(404); + }); + + it.each([ + ["GET", "/7/locks", undefined], + [ + "POST", + "/7/locks", + { action: "acquire", field: "featured", locale: null }, + ], + ["GET", "/7/draft", undefined], + ["PUT", "/7/draft", { locale: null, values: { featured: true } }], + ])("refuses %s %s without can_edit", async (method, path, body) => { + const { send } = await harness(); + + const response = await send(method, path, { body, user: VIEWER }); + + expect(response.status).toBe(403); + }); +}); + +describe("the shared draft", () => { + it("refuses fields the caller holds no lock on", async () => { + const { send } = await harness(); + + const response = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + code: "CONTENT_FIELD_NOT_LOCKED", + fields: ["title"], + }); + }); + + it("refuses a lock held in another language", async () => { + const { lock, send } = await harness(); + await lock("acquire", { locale: "en" }); + + const response = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + expect(response.status).toBe(409); + }); + + it.each([ + ["a shared field with a locale", "pl", { featured: true }], + ["a localized field without one", null, { title: "Hello" }], + ])("refuses %s", async (_name, locale, values) => { + const { send } = await harness(); + + const response = await send("PUT", "/7/draft", { + body: { locale, values }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "CONTENT_DRAFT_WRONG_SCOPE", + }); + }); + + it("refuses a value the field itself would refuse", async () => { + const { lock, send } = await harness(); + await lock("acquire"); + + const response = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "x".repeat(201) } }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + code: "CONTENT_DRAFT_INVALID", + fields: ["title"], + }); + }); + + it("merges two editors' fields into one draft and tells the room", async () => { + const { lock, send } = await harness(); + await lock("acquire", { field: "title" }); + await lock("acquire", { field: "summary", user: BEN }); + + const first = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + const second = await send("PUT", "/7/draft", { + body: { locale: "pl", values: { summary: "Krótko" } }, + user: BEN, + }); + const read = await send("GET", "/7/draft"); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + await expect(read.json()).resolves.toEqual({ + shared: null, + translations: { + pl: { + baseVersion: 5, + updatedAt: "2026-10-08T12:00:00.000Z", + updatedBy: { id: BEN, name: "Ben" }, + values: { summary: "Krótko", title: "Cześć" }, + }, + }, + }); + expect(broadcasts("draft")).toEqual([ + expect.objectContaining({ + by: { id: ANNA, name: "Anna" }, + locale: "pl", + room: ROOM, + values: { title: "Cześć" }, + }), + expect.objectContaining({ + by: { id: BEN, name: "Ben" }, + values: { summary: "Krótko" }, + }), + ]); + }); + + it("keeps the shared draft apart, on the record's version", async () => { + const { lock, send } = await harness(); + await lock("acquire", { field: "featured", locale: null }); + + await send("PUT", "/7/draft", { + body: { locale: null, values: { featured: true } }, + }); + const read = await send("GET", "/7/draft"); + + await expect(read.json()).resolves.toMatchObject({ + shared: { baseVersion: 3, values: { featured: true } }, + translations: {}, + }); + }); +}); + +describe("lifecycle", () => { + it("drops a record's drafts and locks when it is reset", async () => { + const { context, lock, memory, send } = await harness(); + await lock("acquire"); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + + await resetContentLiveRecord(context, ROOM, "restored"); + + expect(memory.rows(core_content_drafts)).toEqual([]); + expect(memory.rows(core_content_field_locks)).toEqual([]); + expect(broadcasts("reset")).toEqual([ + { reason: "restored", room: ROOM, type: "reset" }, + ]); + }); + + it("releases the locks of someone who left, and only theirs", async () => { + const { context, lock, send } = await harness(); + await lock("acquire", { field: "title" }); + await lock("acquire", { field: "summary", user: BEN }); + + await contentLiveUserLeft({ c: context, room: ROOM, userId: ANNA }); + const list = await send("GET", "/7/locks"); + + await expect(list.json()).resolves.toMatchObject({ + locks: [{ field: "summary", user: { id: BEN } }], + }); + expect(broadcasts("locks").at(-1)).toMatchObject({ + locks: [{ field: "summary" }], + }); + }); + + it("sweeps expired locks and stale drafts the record moved past", async () => { + const core = { + contentModels: [{ model: guides, pluginId: PLUGIN_ID }], + }; + const { context, lock, memory, send } = await harness({ core }); + await lock("acquire", { field: "featured", locale: null }); + await send("PUT", "/7/draft", { + body: { locale: null, values: { featured: true } }, + }); + await lock("acquire"); + await send("PUT", "/7/draft", { + body: { locale: "pl", values: { title: "Cześć" } }, + }); + // The shared draft was written on version 3 and the record is now at 4; the + // Polish one is still level with its translation. + vi.spyOn(guides, "service").mockReturnValue({ + findRowById: async () => await Promise.resolve({ id: 7, version: 4 }), + } as never); + vi.setSystemTime(new Date("2026-11-20T12:00:00Z")); + + const swept = await cleanupStaleContentDrafts(context); + + expect(swept).toEqual({ drafts: 1, locks: 2 }); + expect(memory.rows(core_content_drafts)).toEqual([ + expect.objectContaining({ language: "pl" }), + ]); + }); +}); + +describe("route registration", () => { + it("mounts the live routes for editorial content types only", () => { + const paths = buildContentRoutes( + createContentModel(testCategoryContentType), + { pluginId: PLUGIN_ID }, + ).map(entry => entry.route.path); + + expect(paths).not.toContain("/{id}/locks"); + expect( + buildContentRoutes(notes, { pluginId: PLUGIN_ID }).map( + entry => `${entry.route.method} ${entry.route.path}`, + ), + ).toEqual( + expect.arrayContaining([ + "get /{id}/locks", + "post /{id}/locks", + "get /{id}/draft", + "put /{id}/draft", + ]), + ); + }); +}); diff --git a/packages/vitnode/src/content/server/live-routes.ts b/packages/vitnode/src/content/server/live-routes.ts new file mode 100644 index 0000000000..df66ce8c48 --- /dev/null +++ b/packages/vitnode/src/content/server/live-routes.ts @@ -0,0 +1,363 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { ContentLiveRoomRef } from "../live/protocol"; +import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; +import type { ContentModel } from "./model"; +import type { ContentDatabase } from "./service"; + +import { buildRoute } from "../../api/lib/route"; +import { contentTypeName } from "../admin/labels"; +import { CONTENT_PERMISSIONS } from "../const"; +import { + CONTENT_LIVE_CODES, + zodContentDraftRejection, + zodContentDrafts, + zodContentDraftWrite, + zodContentDraftWritten, + zodContentFieldLocked, + zodContentFieldLockList, + zodContentFieldLockRequest, + zodContentFieldLockResponse, +} from "../live/http"; +import { partitionContentFields } from "../localization"; +import { contentFieldUpdateSchema } from "../schemas"; +import { findContentLanguage } from "./language-resolver"; +import { + acquireContentFieldLock, + broadcastContentFieldLocks, + findContentFieldLock, + heldContentFieldLocks, + listContentFieldLocks, + readContentDrafts, + releaseContentFieldLock, + renewContentFieldLock, + writeContentDraft, +} from "./live-store"; +import { broadcastContentLive } from "./live/broadcast"; +import { identifier, jsonBody, jsonResponse, readJson } from "./route-helpers"; + +/** + * Field locks and the shared draft of one editorial content type: the HTTP + * half of live editing. Every route needs `can_edit` - a lock or a draft is + * the start of an edit, and a viewer has no business holding either. + * + * Mounted from `buildContentRoutes` for editorial content types only: a draft + * is measured against the record's `version`, which only they have. + */ +export const buildContentLiveRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition } = model; + const module = definition.permissionModule; + const name = contentTypeName(definition.id); + const permission = { module, permission: CONTENT_PERMISSIONS.edit }; + + const { collectionFields, localizedFields, sharedFields } = + partitionContentFields(definition.fields); + /** What a `null`-locale draft may carry: the same fields `update` takes. */ + const sharedWritable: ContentFieldMap = { + ...sharedFields, + ...collectionFields, + }; + const localized = definition.localization.enabled; + + const params = z.object({ id: z.coerce.number() }); + const notFound = { description: `${name} not found` }; + + const db = (c: Context): ContentDatabase => c.get("db"); + + const roomOf = (id: number): ContentLiveRoomRef => ({ + contentTypeId: definition.id, + itemId: id, + }); + + /** The record, or a 404 - a lock on a record that is not there is nothing. */ + const findRecord = async ( + c: Context, + id: number, + ): Promise> => { + const row = await model.service(c).findRowById(id); + if (!row) { + throw new HTTPException(404, { message: `${name} not found.` }); + } + + return row; + }; + + const editor = (c: Context): { id: number; name: string } => { + const admin = c.get("admin") as null | { + user?: { id?: unknown; name?: unknown }; + }; + const id = admin?.user?.id; + if (typeof id !== "number") { + throw new HTTPException(401, { message: "Sign in to the AdminCP." }); + } + + return { + id, + name: typeof admin?.user?.name === "string" ? admin.user.name : "", + }; + }; + + /** + * The canonical spelling of a language the record can be written in, or a + * 400. A disabled language is read-only, so it takes no lock and no draft. + */ + const resolveLocale = async (c: Context, locale: string): Promise => { + const language = await findContentLanguage(c, locale); + if (!language?.isEnabled) { + throw new HTTPException(400, { message: "Unknown locale." }); + } + + return language.locale; + }; + + /** + * Which language a field is locked or drafted in: `null` for a shared field, + * a real language for a localized one. Anything else is a 400. + */ + const scopeOf = async ( + c: Context, + field: string, + locale: null | string, + ): Promise => { + const isLocalized = localizedFields[field] !== undefined; + if (!isLocalized && sharedWritable[field] === undefined) { + throw new HTTPException(400, { message: "Unknown field." }); + } + if (!isLocalized) { + if (locale !== null) { + throw new HTTPException(400, { + message: "A shared field takes no locale.", + }); + } + + return null; + } + if (locale === null) { + throw new HTTPException(400, { + message: "A localized field needs a locale.", + }); + } + + return await resolveLocale(c, locale); + }; + + const listLocks = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "get", + path: "/{id}/locks", + description: `Field locks held on one ${name}`, + request: { params }, + responses: { + 200: jsonResponse(zodContentFieldLockList, "Unexpired locks"), + 400: { description: "Invalid identifier" }, + 404: notFound, + }, + }, + handler: async c => { + const id = identifier(c); + await findRecord(c, id); + + return c.json( + { locks: await listContentFieldLocks(db(c), roomOf(id)) }, + 200, + ); + }, + }); + + const changeLock = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "post", + path: "/{id}/locks", + description: `Acquire, renew or release a field lock on one ${name}`, + request: { params, body: jsonBody(zodContentFieldLockRequest) }, + responses: { + 200: jsonResponse(zodContentFieldLockResponse, "The caller's lock"), + 400: { description: "Unknown field, or a locale that does not fit it" }, + 404: notFound, + 409: jsonResponse( + zodContentFieldLocked, + "Someone else holds the lock, or the caller no longer does", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const body = await readJson(c, zodContentFieldLockRequest); + const user = editor(c); + const locale = await scopeOf(c, body.field, body.locale); + await findRecord(c, id); + + const room = roomOf(id); + const key = { ...room, field: body.field, locale }; + + if (body.action === "release") { + if (await releaseContentFieldLock(db(c), key, user.id)) { + await broadcastContentFieldLocks(db(c), room); + } + + return c.json({ lock: null }, 200); + } + + const taken = + body.action === "acquire" + ? await acquireContentFieldLock(db(c), key, user.id) + : await renewContentFieldLock(db(c), key, user.id); + const lock = await findContentFieldLock(db(c), key); + + if (!taken || lock?.user.id !== user.id) { + return c.json({ code: CONTENT_LIVE_CODES.locked, lock }, 409); + } + + await broadcastContentFieldLocks(db(c), room); + + return c.json({ lock }, 200); + }, + }); + + const readDraft = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "get", + path: "/{id}/draft", + description: `The shared working copy of one ${name}`, + request: { params }, + responses: { + 200: jsonResponse( + zodContentDrafts, + "The shared and per-language drafts", + ), + 400: { description: "Invalid identifier" }, + 404: notFound, + }, + }, + handler: async c => { + const id = identifier(c); + await findRecord(c, id); + + return c.json(await readContentDrafts(db(c), roomOf(id)), 200); + }, + }); + + const writeDraft = buildRoute({ + pluginId, + adminStaffPermission: permission, + route: { + method: "put", + path: "/{id}/draft", + description: `Autosave fields of one ${name} into its shared draft`, + request: { params, body: jsonBody(zodContentDraftWrite) }, + responses: { + 200: jsonResponse(zodContentDraftWritten, "Draft saved"), + 400: jsonResponse( + zodContentDraftRejection, + "A field outside the locale's scope, or a value the field refuses", + ), + 404: notFound, + 409: jsonResponse( + zodContentDraftRejection, + "The caller holds no lock on some of the fields", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const body = await readJson(c, zodContentDraftWrite); + const user = editor(c); + const names = Object.keys(body.values); + + // Scope first: a localized field only with a locale, a shared field only + // without one - the two live in different drafts. + const scope = body.locale === null ? sharedWritable : localizedFields; + const outOfScope = names.filter(field => scope[field] === undefined); + if (outOfScope.length > 0 || (body.locale !== null && !localized)) { + return c.json( + { + code: CONTENT_LIVE_CODES.draftScope, + fields: outOfScope.length > 0 ? outOfScope : names, + }, + 400, + ); + } + + const locale = + body.locale === null ? null : await resolveLocale(c, body.locale); + const record = await findRecord(c, id); + const room = roomOf(id); + + const held = await heldContentFieldLocks(db(c), { + locale, + names, + room, + userId: user.id, + }); + const unlocked = names.filter(field => !held.has(field)); + if (unlocked.length > 0) { + return c.json( + { code: CONTENT_LIVE_CODES.notLocked, fields: unlocked }, + 409, + ); + } + + const values: Record = {}; + const invalid: string[] = []; + for (const field of names) { + const parsed = contentFieldUpdateSchema(scope, field)?.safeParse( + body.values[field], + ); + if (parsed?.success) values[field] = parsed.data; + else invalid.push(field); + } + if (invalid.length > 0) { + return c.json( + { code: CONTENT_LIVE_CODES.draftInvalid, fields: invalid }, + 400, + ); + } + + // Measured against what the draft was written on, so the cleanup job can + // tell a draft the record has since moved past from live work. + const baseVersion = + locale === null + ? typeof record.version === "number" + ? record.version + : 0 + : ((await model.translationService?.(c).findByLocale(id, locale)) + ?.version ?? 0); + + const updatedAt = await writeContentDraft(db(c), { + baseVersion, + locale, + room, + userId: user.id, + values, + }); + + broadcastContentLive(room, { + by: user, + locale, + room, + type: "draft", + updatedAt: updatedAt.toISOString(), + values, + }); + + return c.json({ updatedAt: updatedAt.toISOString() }, 200); + }, + }); + + return [listLocks, changeLock, readDraft, writeDraft]; +}; diff --git a/packages/vitnode/src/content/server/live-store.ts b/packages/vitnode/src/content/server/live-store.ts new file mode 100644 index 0000000000..66683a2733 --- /dev/null +++ b/packages/vitnode/src/content/server/live-store.ts @@ -0,0 +1,410 @@ +import type { Context } from "hono"; + +import { and, eq, gt, inArray, lt, or, sql } from "drizzle-orm"; + +import type { ContentDraft, ContentDrafts } from "../live/http"; +import type { ContentFieldLock, ContentLiveRoomRef } from "../live/protocol"; +import type { ContentDatabase } from "./service"; + +import { + core_content_drafts, + core_content_field_locks, +} from "../../database/content-drafts"; +import { core_users } from "../../database/users"; +import { + CONTENT_FIELD_LOCK_LEASE_MS, + CONTENT_LIVE_SHARED_LOCALE, + CONTENT_LIVE_STALE_DAYS, +} from "../live/protocol"; +import { broadcastContentLive } from "./live/broadcast"; +import { onContentLiveReset, onContentLiveUserLeft } from "./live/hooks"; +import { findContentModel } from "./model"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const locks = core_content_field_locks; +const drafts = core_content_drafts; + +/** The `language` column value for a lock or draft in `locale`. */ +export const contentLiveLanguage = (locale: null | string): string => + locale ?? CONTENT_LIVE_SHARED_LOCALE; + +const localeOf = (language: string): null | string => + language === CONTENT_LIVE_SHARED_LOCALE ? null : language; + +const inRoom = ( + table: typeof core_content_drafts | typeof core_content_field_locks, + { contentTypeId, itemId }: ContentLiveRoomRef, +) => and(eq(table.contentTypeId, contentTypeId), eq(table.itemId, itemId)); + +export interface ContentFieldLockKey extends ContentLiveRoomRef { + field: string; + locale: null | string; +} + +const lockKey = (key: ContentFieldLockKey) => + and( + inRoom(locks, key), + eq(locks.field, key.field), + eq(locks.language, contentLiveLanguage(key.locale)), + ); + +const lockSelection = { + expiresAt: locks.expiresAt, + field: locks.field, + language: locks.language, + userId: locks.userId, + userName: core_users.name, +}; + +const toLock = (row: { + expiresAt: Date; + field: string; + language: string; + userId: number; + userName: null | string; +}): ContentFieldLock => ({ + expiresAt: new Date(row.expiresAt).toISOString(), + field: row.field, + locale: localeOf(row.language), + user: { id: row.userId, name: row.userName ?? "" }, +}); + +/** Every unexpired lock of one record. */ +export const listContentFieldLocks = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, + now = new Date(), +): Promise => { + const rows = await db + .select(lockSelection) + .from(locks) + .leftJoin(core_users, eq(core_users.id, locks.userId)) + .where(and(inRoom(locks, room), gt(locks.expiresAt, now))) + .orderBy(locks.field, locks.language); + + return rows.map(toLock); +}; + +/** The unexpired lock on one field, whoever holds it. */ +export const findContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + now = new Date(), +): Promise => { + const [row] = await db + .select(lockSelection) + .from(locks) + .leftJoin(core_users, eq(core_users.id, locks.userId)) + .where(and(lockKey(key), gt(locks.expiresAt, now))) + .limit(1); + + return row ? toLock(row) : null; +}; + +/** + * Takes the lock when it is free, expired, or already the caller's - one + * atomic upsert, so two editors racing for the same field cannot both win. + * `false` when someone else holds an unexpired lease. + */ +export const acquireContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + userId: number, + now = new Date(), +): Promise => { + const expiresAt = new Date(now.getTime() + CONTENT_FIELD_LOCK_LEASE_MS); + + const rows = await db + .insert(locks) + .values({ + acquiredAt: now, + contentTypeId: key.contentTypeId, + expiresAt, + field: key.field, + itemId: key.itemId, + language: contentLiveLanguage(key.locale), + userId, + }) + .onConflictDoUpdate({ + set: { acquiredAt: now, expiresAt, userId }, + target: [locks.contentTypeId, locks.itemId, locks.field, locks.language], + // The existing row: its lease ran out, or it is already the caller's. + where: or(lt(locks.expiresAt, now), eq(locks.userId, userId)), + }) + .returning({ id: locks.id }); + + return rows.length > 0; +}; + +/** Extends the caller's own lease. `false` when the lock is not theirs. */ +export const renewContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + userId: number, + now = new Date(), +): Promise => { + const rows = await db + .update(locks) + .set({ expiresAt: new Date(now.getTime() + CONTENT_FIELD_LOCK_LEASE_MS) }) + .where(and(lockKey(key), eq(locks.userId, userId))) + .returning({ id: locks.id }); + + return rows.length > 0; +}; + +/** Drops the caller's own lock. Idempotent; `false` when nothing was held. */ +export const releaseContentFieldLock = async ( + db: ContentDatabase, + key: ContentFieldLockKey, + userId: number, +): Promise => { + const rows = await db + .delete(locks) + .where(and(lockKey(key), eq(locks.userId, userId))) + .returning({ id: locks.id }); + + return rows.length > 0; +}; + +/** The fields of `names` the caller holds an unexpired lock on in `locale`. */ +export const heldContentFieldLocks = async ( + db: ContentDatabase, + { + locale, + names, + room, + userId, + }: { + locale: null | string; + names: readonly string[]; + room: ContentLiveRoomRef; + userId: number; + }, + now = new Date(), +): Promise> => { + if (names.length === 0) return new Set(); + + const rows = await db + .select({ field: locks.field }) + .from(locks) + .where( + and( + inRoom(locks, room), + eq(locks.language, contentLiveLanguage(locale)), + eq(locks.userId, userId), + gt(locks.expiresAt, now), + inArray(locks.field, [...names]), + ), + ); + + return new Set(rows.map(row => row.field)); +}; + +/** Tells the room who holds what now. */ +export const broadcastContentFieldLocks = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, +): Promise => { + const list = await listContentFieldLocks(db, room); + broadcastContentLive(room, { locks: list, room, type: "locks" }); + + return list; +}; + +/** One record's drafts: the shared one, and one per language. */ +export const readContentDrafts = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, +): Promise => { + const rows = await db + .select({ + baseVersion: drafts.baseVersion, + language: drafts.language, + updatedAt: drafts.updatedAt, + updatedById: drafts.updatedBy, + updatedByName: core_users.name, + values: drafts.values, + }) + .from(drafts) + .leftJoin(core_users, eq(core_users.id, drafts.updatedBy)) + .where(inRoom(drafts, room)); + + const result: ContentDrafts = { shared: null, translations: {} }; + for (const row of rows) { + const draft: ContentDraft = { + baseVersion: row.baseVersion, + updatedAt: new Date(row.updatedAt).toISOString(), + updatedBy: + row.updatedById === null + ? null + : { id: row.updatedById, name: row.updatedByName ?? "" }, + values: row.values, + }; + const locale = localeOf(row.language); + + if (locale === null) result.shared = draft; + else result.translations[locale] = draft; + } + + return result; +}; + +/** + * Merges `values` into one language's draft, creating it on the first write. + * + * The merge happens in the database (`values || $new`), so two editors saving + * different fields of the same draft at the same moment both land. + */ +export const writeContentDraft = async ( + db: ContentDatabase, + { + baseVersion, + locale, + room, + userId, + values, + }: { + baseVersion: number; + locale: null | string; + room: ContentLiveRoomRef; + userId: number; + values: Record; + }, + now = new Date(), +): Promise => { + await db + .insert(drafts) + .values({ + baseVersion, + contentTypeId: room.contentTypeId, + itemId: room.itemId, + language: contentLiveLanguage(locale), + updatedAt: now, + updatedBy: userId, + values, + }) + .onConflictDoUpdate({ + set: { + baseVersion, + updatedAt: now, + updatedBy: userId, + values: sql`${drafts.values} || ${JSON.stringify(values)}::jsonb`, + }, + target: [drafts.contentTypeId, drafts.itemId, drafts.language], + }); + + return now; +}; + +/** Throws a record's working state away: every draft and every lock. */ +export const deleteContentLiveState = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, +): Promise => { + await db.delete(drafts).where(inRoom(drafts, room)); + await db.delete(locks).where(inRoom(locks, room)); +}; + +/** Drops every lock one person holds in one record. */ +export const deleteUserContentFieldLocks = async ( + db: ContentDatabase, + room: ContentLiveRoomRef, + userId: number, +): Promise => { + const rows = await db + .delete(locks) + .where(and(inRoom(locks, room), eq(locks.userId, userId))) + .returning({ id: locks.id }); + + return rows.length; +}; + +/** + * The cron's sweep: every expired lock, and every draft untouched for + * `CONTENT_LIVE_STALE_DAYS` that is behind its record - the record moved on, + * was deleted, or its content type is gone. A stale draft that is still level + * with its record is kept: it is somebody's unsaved work. + */ +export const cleanupStaleContentDrafts = async ( + c: Context, + now = new Date(), +): Promise<{ drafts: number; locks: number }> => { + const db: ContentDatabase = c.get("db"); + + const expired = await db + .delete(locks) + .where(lt(locks.expiresAt, now)) + .returning({ id: locks.id }); + + const stale = await db + .select({ + baseVersion: drafts.baseVersion, + contentTypeId: drafts.contentTypeId, + id: drafts.id, + itemId: drafts.itemId, + language: drafts.language, + }) + .from(drafts) + .where( + lt( + drafts.updatedAt, + new Date(now.getTime() - CONTENT_LIVE_STALE_DAYS * DAY_MS), + ), + ); + + const models = c.get("core")?.contentModels ?? []; + const doomed: number[] = []; + + for (const draft of stale) { + const entry = findContentModel(models, draft.contentTypeId); + if (!entry) { + doomed.push(draft.id); + continue; + } + + const locale = localeOf(draft.language); + const record = await entry.model.service(c).findRowById(draft.itemId); + if (!record) { + doomed.push(draft.id); + continue; + } + + if (locale === null) { + const version = (record as Record).version; + if (typeof version === "number" && version > draft.baseVersion) { + doomed.push(draft.id); + } + continue; + } + + const translation = await entry.model + .translationService?.(c) + .findByLocale(draft.itemId, locale); + if ( + translation + ? translation.version > draft.baseVersion + : draft.baseVersion > 0 + ) { + doomed.push(draft.id); + } + } + + if (doomed.length > 0) { + await db.delete(drafts).where(inArray(drafts.id, doomed)); + } + + return { drafts: doomed.length, locks: expired.length }; +}; + +// The socket side tells the HTTP side when someone left or a record was reset, +// without either importing the other. +onContentLiveUserLeft(async ({ c, room, userId }) => { + const db: ContentDatabase = c.get("db"); + const removed = await deleteUserContentFieldLocks(db, room, userId); + if (removed > 0) await broadcastContentFieldLocks(db, room); +}); + +onContentLiveReset(async ({ c, room }) => { + await deleteContentLiveState(c.get("db"), room); +}); diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index f344037587..8c2e109fc0 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -244,6 +244,12 @@ describe("the generated permission matrix", () => { // because one Save button writes one record. "PUT /{id}/localized": "can_edit", "PUT /{id}/translations/{locale}": "can_edit", + // Live editing: a lock or an autosaved draft is the start of an edit, so a + // viewer can neither hold one nor read the team's unsaved working copy. + "GET /{id}/draft": "can_edit", + "GET /{id}/locks": "can_edit", + "POST /{id}/locks": "can_edit", + "PUT /{id}/draft": "can_edit", }; it("gates every route on a staff permission", async () => { diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 8f117f4d20..90f62fbe0c 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -62,6 +62,8 @@ import { withContentReferenceLists, zodContentReferenceListItem, } from "./list-references"; +import { buildContentLiveRoutes } from "./live-routes"; +import { resetContentLiveRecord } from "./live/hooks"; import { contentLocaleRouting } from "./locale-routing"; import { buildContentLocalizedAdminRoutes } from "./localized-admin-routes"; import { resolveContentLocalizedValues } from "./localized-display"; @@ -1232,6 +1234,11 @@ export const buildContentRoutes = < } await contentEditorialEffects(c, definition, result, { model, pluginId }); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: id }, + "restored", + ); return c.json({ changed: result.changed, row: result.row }, 200); }, @@ -1681,6 +1688,11 @@ export const buildContentRoutes = < if (!result) throw notFound(definition); await contentEditorialEffects(c, definition, result, { model, pluginId }); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: id }, + "deleted", + ); return c.json(result.row, 200); }, @@ -1743,6 +1755,8 @@ export const buildContentRoutes = < ? [publicationRoute("publish"), publicationRoute("unpublish")] : []), ...(editorial ? [revisionList, revisionDetail, restore] : []), + // Field locks and the shared draft: live editing needs a record version. + ...(editorial ? buildContentLiveRoutes(model, { pluginId }) : []), // Mounted only for a content type that declares a file field, so nothing // else gains a binary endpoint it has no use for. ...(hasFileFields ? [upload] : []), diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts index ca4a172c50..bb6f9a8c66 100644 --- a/packages/vitnode/src/content/server/translation-routes.ts +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -25,6 +25,7 @@ import { CONTENT_TRANSLATION_REVISION_OPERATIONS, } from "../const"; import { resolveContentActor } from "./actor"; +import { resetContentLiveRecord } from "./live/hooks"; import { contentLocaleRouting } from "./locale-routing"; import { assertContentPreviewIsServable, @@ -629,6 +630,11 @@ export const buildContentTranslationRoutes = < } await announce(c, outcome); + await resetContentLiveRecord( + c, + { contentTypeId: definition.id, itemId: id }, + "restored", + ); return c.json({ changed: outcome.changed, row: outcome.row }, 200); }, diff --git a/packages/vitnode/src/database/content-drafts.ts b/packages/vitnode/src/database/content-drafts.ts new file mode 100644 index 0000000000..c6f60d014b --- /dev/null +++ b/packages/vitnode/src/database/content-drafts.ts @@ -0,0 +1,89 @@ +import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core"; + +import { CONTENT_LOCALE_MAX_LENGTH } from "../content/const"; +import { core_users } from "./users"; + +/** + * One person's lease on one field of one record, in one language. + * + * `language` is `""` (`CONTENT_LIVE_SHARED_LOCALE`) for a shared field rather + * than `NULL`, so the unique key below enforces exactly one holder in both + * cases - Postgres treats every `NULL` as distinct. + */ +export const core_content_field_locks = camelCase.table.withRLS( + "core_content_field_locks", + t => ({ + id: t.serial().primaryKey(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + field: t.varchar({ length: 100 }).notNull(), + language: t + .varchar({ length: CONTENT_LOCALE_MAX_LENGTH }) + .notNull() + .default(""), + userId: t + .integer() + .notNull() + .references(() => core_users.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + acquiredAt: t.timestamp().notNull().defaultNow(), + expiresAt: t.timestamp().notNull(), + }), + t => [ + // The lease itself: acquiring is one upsert against this key. + uniqueIndex("core_content_field_locks_key_unique").on( + t.contentTypeId, + t.itemId, + t.field, + t.language, + ), + // The cleanup job's read: everything past its lease. + index("core_content_field_locks_expires_at_idx").on(t.expiresAt), + index("core_content_field_locks_user_id_idx").on(t.userId), + ], +); + +export type ContentFieldLockRow = typeof core_content_field_locks.$inferSelect; + +/** + * The team's shared working copy of one record in one language: the values + * autosave wrote and `Save` has not committed yet. Never the record itself. + */ +export const core_content_drafts = camelCase.table.withRLS( + "core_content_drafts", + t => ({ + id: t.serial().primaryKey(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + language: t + .varchar({ length: CONTENT_LOCALE_MAX_LENGTH }) + .notNull() + .default(""), + values: t.jsonb().$type>().notNull().default({}), + /** The record's (or translation's) version the draft was last written on. */ + baseVersion: t.integer().notNull().default(0), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + updatedBy: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + }), + t => [ + uniqueIndex("core_content_drafts_key_unique").on( + t.contentTypeId, + t.itemId, + t.language, + ), + // The cleanup job's read: drafts nobody touched for a while. + index("core_content_drafts_updated_at_idx").on(t.updatedAt), + index("core_content_drafts_updated_by_idx").on(t.updatedBy), + ], +); + +export type ContentDraftRow = typeof core_content_drafts.$inferSelect; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 82473aa549..14086a8fc8 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1627,6 +1627,26 @@ "errors": { "version_conflict": "Someone else saved this language while you were editing." } + }, + "live": { + "is_editing": "is editing", + "locked_desc": "Read-only while they edit. Their changes show up here as they type.", + "status": { + "saving": "Saving…", + "saved": "Draft saved at {time}", + "unsaved": "Unsaved changes", + "failed": "Draft not saved" + }, + "reset": { + "restored": { + "title": "This record was restored", + "desc": "Someone restored an earlier version, so the form now shows it. Unsaved drafts were cleared." + }, + "deleted": { + "title": "This record was deleted", + "desc": "Someone deleted it while you were editing. Nothing here can be saved anymore." + } + } } }, "editor": { diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index ba84298aab..5cb711acd8 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -18,6 +18,12 @@ import { readContentRowInBrowser, setContentPublishedInBrowser, } from "@/views/admin/views/content/form/mutations-api"; +import { + changeContentLockInBrowser, + listContentLocksInBrowser, + readContentDraftInBrowser, + saveContentDraftInBrowser, +} from "@/views/admin/views/content/live/api"; import { contentApiTarget, invalidateContentAfterWrite } from "../query"; @@ -104,9 +110,20 @@ export const contentFormTransport = ( return result; }, + listLocks: async (contentTypeId, itemId) => + await listContentLocksInBrowser(targetFor(contentTypeId), itemId), + listTranslations: async (contentTypeId, itemId) => await listContentTranslationsInBrowser(targetFor(contentTypeId), itemId), + // Autosave never touches the record, so nothing cached about it is stale. + lock: async (contentTypeId, itemId, request) => + await changeContentLockInBrowser( + targetFor(contentTypeId), + itemId, + request, + ), + loadOptions: async (contentTypeId, field, search, ids) => await loadContentOptionsInBrowser( targetFor(contentTypeId), @@ -127,9 +144,15 @@ export const contentFormTransport = ( return result; }, + readDraft: async (contentTypeId, itemId) => + await readContentDraftInBrowser(targetFor(contentTypeId), itemId), + reloadRow: async (contentTypeId, itemId) => await readContentRowInBrowser(targetFor(contentTypeId), itemId), + saveDraft: async (contentTypeId, itemId, draft) => + await saveContentDraftInBrowser(targetFor(contentTypeId), itemId, draft), + unpublish: async (contentTypeId, itemId) => { const result = await setContentPublishedInBrowser( targetFor(contentTypeId), diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index fc5a51d645..677442b92a 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -70,7 +70,7 @@ const originalNameOf = (table: Table): string => { }; const WORD_PATTERN = - /\s*(>=|<=|<>|!=|::|[(),*=<>]|'(?:[^']|'')*'|"[^"]*"|\d+(?:\.\d+)?|[A-Za-z_][\w]*)/y; + /\s*(>=|<=|<>|!=|::|\|\||[(),*=<>]|'(?:[^']|'')*'|"[^"]*"|\d+(?:\.\d+)?|[A-Za-z_][\w]*)/y; const tokenizeText = (text: string): Token[] => { const tokens: Token[] = []; @@ -206,8 +206,24 @@ const parseTokens = (tokens: Token[], source: string) => { return node; }; + // `||` binds tighter than any comparison: jsonb merge, or text concatenation. + const parseConcat = (): Node => { + let node = parseCast(); + while (isWord("||")) { + position++; + node = { + kind: "compare", + left: node, + operator: "||", + right: parseCast(), + }; + } + + return node; + }; + const parseComparison = (): Node => { - const left = parseCast(); + const left = parseConcat(); const token = peek(); if (token?.kind !== "word") return left; @@ -218,7 +234,7 @@ const parseTokens = (tokens: Token[], source: string) => { kind: "compare", left, operator: token.value === "!=" ? "<>" : token.value, - right: parseCast(), + right: parseConcat(), }; } if (token.value === "is") { @@ -366,8 +382,21 @@ const textOf = (value: unknown): string => { const isMissing = (value: unknown): value is null | undefined => value === null || value === undefined; +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + const compare = (left: unknown, operator: string, right: unknown) => { if (isMissing(left) || isMissing(right)) return null; + if (operator === "||") { + if (isPlainObject(left) && isPlainObject(right)) { + return { ...left, ...right }; + } + if (typeof left === "string" && typeof right === "string") { + return left + right; + } + + return unsupported(`"||" between ${typeof left} and ${typeof right}`); + } if (operator === "=") return equal(left, right); if (operator === "<>") return !equal(left, right); const a = comparable(left) as number | string; diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 0473630f19..4d33c55f5f 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -25,6 +25,7 @@ import type { TranslationRow } from "../content-mutation"; import type { ContentFormHeaderValue } from "../form/context"; import type { ContentFormSkeletonOverrides } from "../form/skeleton"; import type { ContentOptionsLoader } from "../lib/field-component"; +import type { ContentLiveFormState } from "../live/root"; import type { ContentConflictState } from "./conflict-notice"; import { ContentFormProvider } from "../form/context"; @@ -45,6 +46,10 @@ import { useContentFormTransport } from "../form/transport"; import { ContentField } from "../lib/field-component"; import { contentErrorKey } from "../lib/mutation-feedback"; import { useInvalidateContentOptions } from "../lib/options-query"; +import { ContentLiveField } from "../live/field"; +import { ContentLiveRoot } from "../live/root"; +import { ContentLiveStatus } from "../live/status"; +import { overlayContentDrafts } from "../live/values"; import { ConflictNotice } from "./conflict-notice"; export interface ContentFormProps { @@ -122,19 +127,42 @@ export const ContentForm = ({ }; }, [contentTypeId, data, pendingRow, transport]); - if (loaded === null || pendingRow) { + const skeleton = ( + + ); + + if (loaded === null || pendingRow) return skeleton; + + // Live editing (locks, the shared draft, autosave) needs a record version, + // so it is an existing record of an editorial content type or nothing. + if (row && spec.editorial === true) { return ( - + + {live => ( + + )} + ); } @@ -153,6 +181,7 @@ const ContentFormFields = ({ fieldOverrides = {}, header, layout, + live, onCreated, presentation = "dialog", publication = false, @@ -160,7 +189,10 @@ const ContentFormFields = ({ spec, title, translations = [], -}: ContentFormProps) => { +}: ContentFormProps & { + /** The shared draft the form opens on, when the record is edited live. */ + live?: ContentLiveFormState; +}) => { const t = useTranslations("core.content"); const tErrors = useTranslations("core.global.errors"); const tContentErrors = useTranslations("core.content.errors"); @@ -203,10 +235,17 @@ const ContentFormFields = ({ const [opened, setOpened] = React.useState(() => translations); - const values = React.useMemo( - () => contentFormInitialValues(spec, data, opened), - [spec, data, opened], - ); + const drafts = live?.drafts ?? null; + const draftLabels = live?.labels; + const values = React.useMemo(() => { + if (!data || !drafts) return contentFormInitialValues(spec, data, opened); + + // The draft only changes what the form opens on. `data` and `opened` stay + // the committed record, so Save still sees every drafted value as a change. + const overlaid = overlayContentDrafts(data, opened, drafts, draftLabels); + + return contentFormInitialValues(spec, overlaid.data, overlaid.translations); + }, [spec, data, opened, drafts, draftLabels]); const formSchema = React.useMemo( () => buildFormSchemaFromSpec(spec, values), @@ -425,22 +464,24 @@ const ContentFormFields = ({ // suspends on promise children, so the dialog spins forever. component: props => { const Override = fieldOverrides[fieldSpec.name]; - if (Override) { - return ( - - ); - } + // A no-op outside a live session; inside one it holds the field's lock. return ( - - await uploadContentFile({ field, file, spec }) - } - {...props} - /> + + {Override ? ( + + ) : ( + + await uploadContentFile({ field, file, spec }) + } + {...props} + /> + )} + ); }, }), @@ -506,6 +547,7 @@ const ContentFormFields = ({ ) : ( <> + {presentation === "page" ? null : } {publication && data ? ( { const { header, markHeaderRendered } = useContentForm(); + const live = useContentLive(); markHeaderRendered?.(); @@ -22,7 +25,16 @@ export const ContentFormHeader = ({ + {header.desc} + + + ) : ( + header.desc + ) + } h1={header.title} > {children} diff --git a/packages/vitnode/src/views/admin/views/content/form/transport.tsx b/packages/vitnode/src/views/admin/views/content/form/transport.tsx index 65bd7d7497..277660515c 100644 --- a/packages/vitnode/src/views/admin/views/content/form/transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/transport.tsx @@ -1,5 +1,8 @@ import React from "react"; +import type { ContentDrafts } from "@/content/live/http"; +import type { ContentFieldLock } from "@/content/live/protocol"; + import type { ContentMutationResult, ContentRowResult, @@ -7,6 +10,11 @@ import type { TranslationRow, } from "../content-mutation"; import type { ContentOption } from "../lib/field-component"; +import type { + ContentDraftSaveResult, + ContentFieldLockAction, + ContentFieldLockResult, +} from "../live/api"; export interface ContentFormTransport { /** Creates a record from the shared fields alone. */ @@ -36,6 +44,12 @@ export interface ContentFormTransport { expectedVersion?: number, ) => Promise; + /** Live editing: every unexpired field lock of one record. */ + listLocks: ( + contentTypeId: string, + itemId: number, + ) => Promise<{ error?: string; locks: ContentFieldLock[] }>; + listTranslations: ( contentTypeId: string, itemId: number, @@ -47,16 +61,46 @@ export interface ContentFormTransport { search: string, ids?: number[], ) => Promise; + + /** + * Live editing: takes, extends or gives back the caller's lease on one field + * in one language (`null` for a shared field). + */ + lock: ( + contentTypeId: string, + itemId: number, + request: { + action: ContentFieldLockAction; + field: string; + locale: null | string; + }, + ) => Promise; /** Moves a record to `published`. Idempotent: a no-op is a success. */ publish: ( contentTypeId: string, itemId: number, ) => Promise; + /** Live editing: the record's shared draft and one draft per language. */ + readDraft: ( + contentTypeId: string, + itemId: number, + ) => Promise<{ drafts?: ContentDrafts; error?: string }>; + reloadRow: ( contentTypeId: string, itemId: number, ) => Promise; + + /** + * Live editing: merges the fields the caller holds a lock on into the + * draft of `locale` (`null` for the shared fields). + */ + saveDraft: ( + contentTypeId: string, + itemId: number, + draft: { locale: null | string; values: Record }, + ) => Promise; /** Moves a record back to `draft`. Idempotent, like {@link publish}. */ unpublish: ( contentTypeId: string, diff --git a/packages/vitnode/src/views/admin/views/content/live/api.ts b/packages/vitnode/src/views/admin/views/content/live/api.ts new file mode 100644 index 0000000000..55769a4950 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/api.ts @@ -0,0 +1,122 @@ +import type { + ContentDraftRejection, + ContentDrafts, + ContentFieldLockAction, +} from "@/content/live/http"; +import type { ContentFieldLock } from "@/content/live/protocol"; + +import { + zodContentDraftRejection, + zodContentDrafts, + zodContentDraftWritten, + zodContentFieldLocked, + zodContentFieldLockList, + zodContentFieldLockResponse, +} from "@/content/live/http"; + +import type { ContentApiTarget } from "../content-request"; + +import { sendContentApiRequest as send } from "../lib/api-result"; + +export type { ContentFieldLockAction }; + +export interface ContentFieldLockResult { + error?: string; + /** The caller's lock after the change; `null` once released. */ + lock?: ContentFieldLock | null; + /** Someone else holds it (`409`): who, when the API said. */ + lockedBy?: ContentFieldLock | null; + status: number; +} + +export interface ContentDraftSaveResult { + error?: string; + /** Which fields were refused and why, for a `400` or a `409`. */ + rejection?: ContentDraftRejection; + status: number; + updatedAt?: string; +} + +const parseJson = (text: string | undefined): unknown => { + if (!text) return undefined; + + try { + return JSON.parse(text); + } catch { + return undefined; + } +}; + +export const listContentLocksInBrowser = async ( + target: ContentApiTarget, + id: number, +): Promise<{ error?: string; locks: ContentFieldLock[] }> => { + const result = await send( + { method: "get", path: `/${id}/locks`, target }, + zodContentFieldLockList, + ); + + return result.data + ? { locks: result.data.locks } + : { error: result.error ?? "", locks: [] }; +}; + +export const changeContentLockInBrowser = async ( + target: ContentApiTarget, + id: number, + body: { + action: ContentFieldLockAction; + field: string; + locale: null | string; + }, +): Promise => { + const result = await send( + { body, method: "post", path: `/${id}/locks`, target }, + zodContentFieldLockResponse, + ); + + if (result.data) return { lock: result.data.lock, status: result.status }; + + const locked = zodContentFieldLocked.safeParse(parseJson(result.error)); + + return { + error: result.error ?? "", + ...(locked.success ? { lockedBy: locked.data.lock } : {}), + status: result.status, + }; +}; + +export const readContentDraftInBrowser = async ( + target: ContentApiTarget, + id: number, +): Promise<{ drafts?: ContentDrafts; error?: string }> => { + const result = await send( + { method: "get", path: `/${id}/draft`, target }, + zodContentDrafts, + ); + + return result.data ? { drafts: result.data } : { error: result.error ?? "" }; +}; + +export const saveContentDraftInBrowser = async ( + target: ContentApiTarget, + id: number, + body: { locale: null | string; values: Record }, +): Promise => { + const result = await send( + { body, method: "put", path: `/${id}/draft`, target }, + zodContentDraftWritten, + ); + + if (result.data) { + return { status: result.status, updatedAt: result.data.updatedAt }; + } + + const rejection = zodContentDraftRejection.safeParse(parseJson(result.error)); + + return { + error: result.error ?? "", + ...(rejection.success ? { rejection: rejection.data } : {}), + status: result.status, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/context.ts b/packages/vitnode/src/views/admin/views/content/live/context.ts new file mode 100644 index 0000000000..55a6438da8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/context.ts @@ -0,0 +1,42 @@ +import React from "react"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +import type { ContentLiveSession } from "./use-session"; + +export interface ContentLiveAutosaveStatus { + /** The draft holds values the record does not: `Save` has work to do. */ + dirty: boolean; + /** The last autosave was refused or never arrived. */ + failed: boolean; + /** When this tab's last autosave landed, as an ISO string. */ + savedAt: null | string; + saving: boolean; +} + +export interface ContentLiveContextValue { + autosave: { + /** Saves whatever is waiting now, rather than after the debounce. */ + flush: () => Promise; + /** Schedules one field's API value for the next autosave. */ + queue: (field: string, locale: null | string, value: unknown) => void; + }; + /** The record the session is for. */ + itemId: number; + /** The language a localized field locks in when the layout pins none. */ + locale: null | string; + session: ContentLiveSession; + spec: ContentFormSpec; + status: ContentLiveAutosaveStatus; +} + +export const ContentLiveContext = + React.createContext(null); + +/** + * The live editing session of the record the form edits: who is in it, who + * holds which field, and the autosave status. `null` outside a live form - a + * create form, or a content type without editorial versions. + */ +export const useContentLive = (): ContentLiveContextValue | null => + React.use(ContentLiveContext); diff --git a/packages/vitnode/src/views/admin/views/content/live/field.test.tsx b/packages/vitnode/src/views/admin/views/content/live/field.test.tsx new file mode 100644 index 0000000000..ac511bc810 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/field.test.tsx @@ -0,0 +1,231 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AnyFormFieldApi } from "@/components/form/auto-form"; +import type { + ContentFormFieldSpec, + ContentFormSpec, +} from "@/content/admin/spec"; +import type { ContentFieldLock } from "@/content/live/protocol"; + +import type { ContentFormTransport } from "../form/transport"; +import type { ContentLiveContextValue } from "./context"; +import type { ContentLiveDraftEvent, ContentLiveSession } from "./use-session"; + +import { ContentFormTransportProvider } from "../form/transport"; +import { ContentLiveContext } from "./context"; +import { CONTENT_FIELD_LOCK_BLUR_GRACE_MS, ContentLiveField } from "./field"; + +const ME = 1; +const ANNA = 2; + +const fieldSpec: ContentFormFieldSpec = { + kind: "text", + label: "Title", + name: "title", + nullable: false, + required: true, +}; + +const spec: ContentFormSpec = { + contentTypeId: "test.note", + defaultLocale: null, + editorial: true, + fields: [fieldSpec], + permissionModule: "notes", + pluginId: "@vitnode/example", + sections: [], + titleField: "title", +}; + +const lockOf = (userId: number, name: string): ContentFieldLock => ({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + field: "title", + locale: null, + user: { id: userId, name }, +}); + +const setup = ({ locks = [] }: { locks?: ContentFieldLock[] } = {}) => { + const draftListeners = new Set<(event: ContentLiveDraftEvent) => void>(); + const session: ContentLiveSession = { + clientId: "tab-1", + focus: vi.fn(), + live: false, + locks, + members: [], + onDraft: listener => { + draftListeners.add(listener); + + return () => { + draftListeners.delete(listener); + }; + }, + onReset: () => () => {}, + readDrafts: async () => await Promise.resolve(null), + readyState: 3, + refreshLocks: vi.fn(async () => { + await Promise.resolve(); + }), + rememberSelf: vi.fn(), + self: ME, + }; + const live: ContentLiveContextValue = { + autosave: { + flush: vi.fn(async () => { + await Promise.resolve(); + }), + queue: vi.fn(), + }, + itemId: 7, + locale: null, + session, + spec, + status: { dirty: false, failed: false, savedAt: null, saving: false }, + }; + const lock = vi.fn( + async (_type, _id, { action }) => + await Promise.resolve({ + lock: action === "release" ? null : lockOf(ME, "Me"), + status: 200, + }), + ); + const transport = { lock } as unknown as ContentFormTransport; + const field: AnyFormFieldApi = { + name: "title", + onBlur: vi.fn(), + onChange: vi.fn(), + value: "Hello", + }; + + render( + + + + + + + + , + ); + + return { draftListeners, field, live, lock }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ContentLiveField", () => { + it("is read-only and names the holder while someone else edits it", () => { + setup({ locks: [lockOf(ANNA, "Anna")] }); + + // A disabled fieldset disables every control inside it, and says why. + expect(screen.getByRole("group", { description: /Anna/ })).toHaveProperty( + "disabled", + true, + ); + expect( + screen.getByRole("textbox", { name: "Title" }).matches(":disabled"), + ).toBe(true); + expect(screen.getByText("Anna")).toBeTruthy(); + expect(screen.getByText("core.content.live.is_editing")).toBeTruthy(); + }); + + it("stays editable when nobody holds it", () => { + setup(); + + expect( + screen.getByRole("textbox", { name: "Title" }).matches(":disabled"), + ).toBe(false); + expect(screen.queryByText("core.content.live.is_editing")).toBeNull(); + }); + + it("stays editable when the lock is the editor's own", () => { + setup({ locks: [lockOf(ME, "Me")] }); + + expect( + screen.getByRole("textbox", { name: "Title" }).matches(":disabled"), + ).toBe(false); + }); + + it("takes the lock on focus and gives it back once focus has left", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { live, lock } = setup(); + + await act(async () => { + fireEvent.focus(screen.getByRole("textbox", { name: "Title" })); + await Promise.resolve(); + }); + expect(lock).toHaveBeenCalledWith("test.note", 7, { + action: "acquire", + field: "title", + locale: null, + }); + expect(live.session.focus).toHaveBeenCalledWith("title", null); + + await act(async () => { + fireEvent.blur(screen.getByRole("textbox", { name: "Title" }), { + relatedTarget: screen.getByRole("button", { name: "Elsewhere" }), + }); + vi.advanceTimersByTime(CONTENT_FIELD_LOCK_BLUR_GRACE_MS); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(lock).toHaveBeenLastCalledWith("test.note", 7, { + action: "release", + field: "title", + locale: null, + }); + }); + expect(live.autosave.flush).toHaveBeenCalled(); + }); + + it("asks for no lock on a field someone else holds", () => { + const { lock } = setup({ locks: [lockOf(ANNA, "Anna")] }); + + fireEvent.focus(screen.getByRole("textbox", { name: "Title" })); + + expect(lock).not.toHaveBeenCalled(); + }); + + it("shows another editor's autosaved value", () => { + const { draftListeners, field } = setup({ locks: [lockOf(ANNA, "Anna")] }); + + act(() => { + for (const listener of draftListeners) { + listener({ + by: { id: ANNA, name: "Anna" }, + locale: null, + updatedAt: new Date().toISOString(), + values: { title: "Hello from Anna" }, + }); + } + }); + + expect(field.onChange).toHaveBeenCalledWith("Hello from Anna"); + }); + + it("ignores drafts for other fields and other languages", () => { + const { draftListeners, field } = setup(); + + act(() => { + for (const listener of draftListeners) { + listener({ + by: null, + locale: null, + updatedAt: new Date().toISOString(), + values: { body: "Elsewhere" }, + }); + listener({ + by: null, + locale: "pl", + updatedAt: new Date().toISOString(), + values: { title: "Cześć" }, + }); + } + }); + + expect(field.onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/field.tsx b/packages/vitnode/src/views/admin/views/content/live/field.tsx new file mode 100644 index 0000000000..3ac3c04050 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/field.tsx @@ -0,0 +1,322 @@ +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AnyFormFieldApi } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; +import type { ContentFieldLock } from "@/content/live/protocol"; + +import { Avatar } from "@/components/avatar"; +import { useMultiLangLanguage } from "@/components/form/fields/multi-lang-language"; +import { + contentApiValueToFormField, + contentFormFieldToApi, +} from "@/content/admin/spec"; +import { CONTENT_FIELD_LOCK_RENEW_MS } from "@/content/live/protocol"; + +import type { ContentLiveContextValue } from "./context"; + +import { useContentFormTransport } from "../form/transport"; +import { useContentLive } from "./context"; + +/** + * How long a field keeps its lock after focus leaves it. A select or a picker + * opens its list in a portal outside the field, and focus coming straight back + * must not cost the lock - or a round trip. + */ +export const CONTENT_FIELD_LOCK_BLUR_GRACE_MS = 1_500; + +/** `richText` is not a field kind yet: it arrives with the rich text phase. */ +const isRichText = (kind: string): boolean => kind === "richText"; + +const isExpired = (lock: ContentFieldLock): boolean => + Date.parse(lock.expiresAt) <= Date.now(); + +const same = (a: unknown, b: unknown): boolean => + JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + +/** Who holds a field, as the badge shows them. */ +export const ContentFieldLockBadge = ({ + id, + lock, + live, +}: { + id: string; + live: ContentLiveContextValue; + lock: ContentFieldLock | null; +}) => { + const t = useTranslations("core.content.live"); + const member = lock + ? live.session.members.find(entry => entry.userId === lock.user.id) + : undefined; + + return ( +
+ {lock ? ( +

+ + + + + {lock.user.name} + {" "} + {t("is_editing")} + + + {t("locked_desc")} +

+ ) : null} +
+ ); +}; + +const LiveField = ({ + children, + field, + fieldSpec, + live, +}: { + children: React.ReactNode; + field: AnyFormFieldApi; + fieldSpec: ContentFormFieldSpec; + live: ContentLiveContextValue; +}) => { + const transport = useContentFormTransport(); + const pinned = useMultiLangLanguage(); + const badgeId = React.useId(); + const { autosave, session, spec } = live; + const name = fieldSpec.name; + const locale = fieldSpec.localized === true ? (pinned ?? live.locale) : null; + // Rich text is co-edited through Yjs while the socket is live, so everyone + // types in it at once and it takes no lock; without a socket it locks. + const lockable = !(isRichText(fieldSpec.kind) && session.live); + + const [held, setHeld] = React.useState(false); + const heldRef = React.useRef(false); + const busyRef = React.useRef>(Promise.resolve()); + const releaseTimerRef = + React.useRef>(undefined); + const focusedRef = React.useRef(false); + + const lock = + session.locks.find( + entry => + entry.field === name && entry.locale === locale && !isExpired(entry), + ) ?? null; + const lockedByOther = + lockable && + !held && + lock !== null && + (session.self === null || lock.user.id !== session.self); + + const markHeld = (value: boolean) => { + heldRef.current = value; + setHeld(value); + }; + + const request = async (action: "acquire" | "release" | "renew") => + await transport.lock(spec.contentTypeId, live.itemId, { + action, + field: name, + locale, + }); + + const acquire = async () => { + if (heldRef.current || !lockable) return; + + const result = await request("acquire"); + if (result.lock) { + session.rememberSelf(result.lock.user.id); + markHeld(focusedRef.current); + if (!focusedRef.current) await request("release"); + + return; + } + // Someone got there first: the list says who, so the field turns read-only. + void session.refreshLocks(); + }; + + const release = async () => { + if (!heldRef.current) return; + + await autosave.flush(); + markHeld(false); + await request("release"); + }; + + const renew = async () => { + if (!heldRef.current) return; + + const result = await request("renew"); + if (result.lock) return; + + // The lease ran out and someone else took it, or it vanished. + markHeld(false); + if (focusedRef.current) await acquire(); + else void session.refreshLocks(); + }; + + // Timers and the unmount cleanup outlive the render that scheduled them, so + // they reach the lock through the latest render's functions. + const actionsRef = React.useRef({ acquire, release, renew }); + React.useEffect(() => { + actionsRef.current = { acquire, release, renew }; + }); + + /** One lock request at a time per field, in the order they were asked. */ + const run = (action: "acquire" | "release" | "renew") => { + const task = async () => { + await actionsRef.current[action](); + }; + busyRef.current = busyRef.current.then(task, task); + }; + + React.useEffect(() => { + if (!held) return; + + const timer = setInterval(() => { + run("renew"); + }, CONTENT_FIELD_LOCK_RENEW_MS); + + return () => { + clearInterval(timer); + }; + }, [held]); + + React.useEffect( + () => () => { + clearTimeout(releaseTimerRef.current); + if (heldRef.current) run("release"); + }, + [], + ); + + // Autosave what this person types, while the field is theirs - from the + // first change after taking the lock, so merely visiting a field writes + // nothing. + const valueKey = JSON.stringify(field.value ?? null); + const baselineRef = React.useRef(null); + /** The value when focus arrived, which can be before the lock did. */ + const focusValueRef = React.useRef(null); + const queueChange = React.useEffectEvent(() => { + if (!held) { + baselineRef.current = null; + + return; + } + baselineRef.current ??= focusValueRef.current ?? valueKey; + if (baselineRef.current === valueKey) return; + + // Once changed, every later value goes out - reverting is a change too. + baselineRef.current = ""; + const converted = contentFormFieldToApi( + spec, + fieldSpec, + field.value, + locale, + ); + if (converted) autosave.queue(name, locale, converted.value); + }); + React.useEffect(() => { + queueChange(); + }, [held, valueKey]); + + // Everyone else's autosaves land in this field - never while it is ours. + const applyDraft = React.useEffectEvent( + (event: { locale: null | string; values: Record }) => { + if (!(name in event.values)) return; + if (heldRef.current && event.locale === locale) return; + if ( + fieldSpec.localized === true + ? event.locale === null + : event.locale !== null + ) { + return; + } + + const next = contentApiValueToFormField( + fieldSpec, + event.values[name], + field.value, + event.locale, + ); + if (!same(next, field.value)) field.onChange(next); + }, + ); + + const { onDraft } = session; + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- a subscription to the session's draft events, nothing handed upwards + React.useEffect(() => onDraft(applyDraft), [onDraft]); + + const onFocusCapture = () => { + if (!focusedRef.current) focusValueRef.current = valueKey; + focusedRef.current = true; + clearTimeout(releaseTimerRef.current); + session.focus(name, locale); + if (!lockedByOther) run("acquire"); + }; + + const onBlurCapture = (event: React.FocusEvent) => { + const next = event.relatedTarget; + if (next instanceof Node && event.currentTarget.contains(next)) return; + + focusedRef.current = false; + session.focus(null, null); + clearTimeout(releaseTimerRef.current); + releaseTimerRef.current = setTimeout(() => { + if (!focusedRef.current) run("release"); + }, CONTENT_FIELD_LOCK_BLUR_GRACE_MS); + }; + + return ( +
+
+ {children} +
+ +
+ ); +}; + +/** + * One form field inside a live session: focusing it takes the field's lock, + * leaving it gives the lock back, and while someone else holds it the field is + * read-only with their name beside it. Outside a live form it renders the + * field untouched. + */ +export const ContentLiveField = ({ + children, + field, + fieldSpec, +}: { + children: React.ReactNode; + field: AnyFormFieldApi; + fieldSpec: ContentFormFieldSpec; +}) => { + const live = useContentLive(); + if (!live) return <>{children}; + + return ( + + {children} + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/root.tsx b/packages/vitnode/src/views/admin/views/content/live/root.tsx new file mode 100644 index 0000000000..857e78ce91 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/root.tsx @@ -0,0 +1,294 @@ +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentDrafts } from "@/content/live/http"; + +import { CONTENT_DRAFT_AUTOSAVE_MS } from "@/content/live/protocol"; + +import type { ContentRow, TranslationRow } from "../content-mutation"; +import type { + ContentLiveAutosaveStatus, + ContentLiveContextValue, +} from "./context"; +import type { ContentLiveResetReason } from "./use-session"; + +import { useContentFormNavigation } from "../form/navigation"; +import { useContentFormTransport } from "../form/transport"; +import { ContentLiveContext } from "./context"; +import { useContentLiveSession } from "./use-session"; +import { + contentDraftDiffers, + contentDraftMovedReferences, + contentDraftValue, + mergeContentDraft, +} from "./values"; + +/** What the form opens on: the record, its languages, and the shared draft. */ +export interface ContentLiveFormState { + drafts: ContentDrafts | null; + /** Labels for references the draft moved, keyed by field. */ + labels: Record; + row: ContentRow; + translations: readonly TranslationRow[]; +} + +interface Reloaded { + for: ContentRow; + row: ContentRow; + translations: readonly TranslationRow[]; +} + +interface PendingValue { + field: string; + locale: null | string; + value: unknown; +} + +const IDLE: ContentLiveAutosaveStatus = { + dirty: false, + failed: false, + savedAt: null, + saving: false, +}; + +/** + * Live editing around one existing record's form: the room, the draft the + * form opens on, the autosave queue and what happens when the record is + * restored or deleted under it. + * + * Renders `fallback` until the draft has been read, so the form never opens + * on values it is about to replace. After a reset it remounts the form on the + * reloaded record. + */ +export const ContentLiveRoot = ({ + children, + data, + fallback, + spec, + translations, +}: { + children: (state: ContentLiveFormState) => React.ReactNode; + data: ContentRow; + fallback: React.ReactNode; + spec: ContentFormSpec; + translations: readonly TranslationRow[]; +}) => { + const t = useTranslations("core.content.live"); + const transport = useContentFormTransport(); + const { refresh } = useContentFormNavigation(); + const contentTypeId = spec.contentTypeId; + const itemId = data.id; + const session = useContentLiveSession({ + contentTypeId, + itemId, + locale: spec.defaultLocale, + }); + const { onDraft, onReset, readDrafts } = session; + + /** The draft the form opened on: read once, and again after a reset. */ + const [opening, setOpening] = React.useState< + undefined | { drafts: ContentDrafts | null; labels: Record } + >(undefined); + /** The draft as it is now, every autosave folded in. */ + const [drafts, setDrafts] = React.useState(null); + const [reloaded, setReloaded] = React.useState(null); + const [generation, setGeneration] = React.useState(0); + const [status, setStatus] = + React.useState>(IDLE); + + const row = reloaded?.for === data ? reloaded.row : data; + const rows = reloaded?.for === data ? reloaded.translations : translations; + + const draftsRef = React.useRef(drafts); + React.useEffect(() => { + draftsRef.current = drafts; + }, [drafts]); + + /** + * The draft, and the names behind any reference it moved - the form shows a + * picked category by name, and the record only knows its old one's. + */ + const load = React.useEffectEvent(async (base?: ContentRow) => { + const read = await readDrafts(); + const moved = contentDraftMovedReferences(spec, base ?? row, read); + const found = await Promise.all( + moved.map(async ({ field, id }) => { + const [option] = await transport.loadOptions(contentTypeId, field, "", [ + id, + ]); + + return option ? ([[field, option.label]] as const) : []; + }), + ); + + return { labels: Object.fromEntries(found.flat()), read }; + }); + + React.useEffect(() => { + let active = true; + + void load().then(result => { + if (!active) return; + setOpening({ drafts: result.read, labels: result.labels }); + setDrafts(result.read); + }); + + return () => { + active = false; + }; + }, []); + + React.useEffect( + () => + onDraft(event => { + setDrafts(current => mergeContentDraft(current, event)); + }), + [onDraft], + ); + + const pendingRef = React.useRef(new Map()); + const timerRef = React.useRef>(undefined); + const chainRef = React.useRef>(Promise.resolve()); + + const saveNow = React.useCallback(async () => { + clearTimeout(timerRef.current); + timerRef.current = undefined; + + const batch = [...pendingRef.current.values()]; + pendingRef.current.clear(); + if (batch.length === 0) return; + + const byLocale = new Map>(); + for (const { field, locale, value } of batch) { + byLocale.set(locale, { ...byLocale.get(locale), [field]: value }); + } + + setStatus(current => ({ ...current, saving: true })); + let failed = false; + let savedAt: null | string = null; + + for (const [locale, values] of byLocale) { + const result = await transport.saveDraft(contentTypeId, itemId, { + locale, + values, + }); + + if (result.updatedAt === undefined) { + failed = true; + continue; + } + + const updatedAt = result.updatedAt; + savedAt = updatedAt; + setDrafts(current => + mergeContentDraft(current, { + by: null, + locale, + updatedAt, + values, + }), + ); + } + + setStatus(current => ({ + failed, + saving: false, + savedAt: savedAt ?? current.savedAt, + })); + }, [contentTypeId, itemId, transport]); + + const flush = React.useCallback(async () => { + chainRef.current = chainRef.current.then(saveNow, saveNow); + await chainRef.current; + }, [saveNow]); + + const queue = React.useCallback( + (field: string, locale: null | string, value: unknown) => { + const key = `${locale ?? ""}\u0000${field}`; + const stored = contentDraftValue(draftsRef.current, field, locale); + + if (stored && JSON.stringify(stored.value) === JSON.stringify(value)) { + pendingRef.current.delete(key); + + return; + } + + pendingRef.current.set(key, { field, locale, value }); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + void flush(); + }, CONTENT_DRAFT_AUTOSAVE_MS); + }, + [flush], + ); + + // Whatever is still waiting goes out with the form. + React.useEffect( + () => () => { + void flush(); + }, + [flush], + ); + + const reset = React.useEffectEvent(async (reason: ContentLiveResetReason) => { + pendingRef.current.clear(); + clearTimeout(timerRef.current); + + if (reason === "deleted") { + toast.warning(t("reset.deleted.title"), { + description: t("reset.deleted.desc"), + }); + refresh(); + + return; + } + + const [fresh, list] = await Promise.all([ + transport.reloadRow(contentTypeId, itemId), + spec.defaultLocale === null + ? Promise.resolve({ edges: [...rows] }) + : transport.listTranslations(contentTypeId, itemId), + ]); + const next = fresh.row ? { ...data, ...fresh.row } : row; + const { labels, read } = await load(next); + + setReloaded({ for: data, row: next, translations: list.edges }); + setOpening({ drafts: read, labels }); + setDrafts(read); + setStatus(IDLE); + setGeneration(current => current + 1); + toast.info(t("reset.restored.title"), { + description: t("reset.restored.desc"), + }); + refresh(); + }); + + React.useEffect( + () => + onReset(reason => { + void reset(reason); + }), + [onReset], + ); + + if (opening === undefined) return <>{fallback}; + + const value: ContentLiveContextValue = { + autosave: { flush, queue }, + itemId, + locale: spec.defaultLocale, + session, + spec, + status: { ...status, dirty: contentDraftDiffers(row, rows, drafts) }, + }; + + return ( + + + {children({ ...opening, row, translations: rows })} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/status.tsx b/packages/vitnode/src/views/admin/views/content/live/status.tsx new file mode 100644 index 0000000000..809a1cbc63 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/status.tsx @@ -0,0 +1,41 @@ +import { cn } from "cn"; +import { useLocale, useTranslations } from "use-intl"; + +import { useContentLive } from "./context"; + +/** + * "Saving…", "Draft saved at 14:32", "Unsaved changes": what autosave did with + * this person's typing, announced politely. Renders nothing outside a live + * form, so a layout can place it unconditionally. + */ +export const ContentLiveStatus = ({ className }: { className?: string }) => { + const t = useTranslations("core.content.live.status"); + const locale = useLocale(); + const live = useContentLive(); + if (!live) return null; + + const { dirty, failed, savedAt, saving } = live.status; + const saved = savedAt + ? t("saved", { + time: new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format( + new Date(savedAt), + ), + }) + : null; + const message = saving ? t("saving") : failed ? t("failed") : saved; + + return ( +

+ {message ? {message} : null} + {dirty ? ( + {t("unsaved")} + ) : null} +

+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx new file mode 100644 index 0000000000..53bce9f8a7 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx @@ -0,0 +1,335 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ContentLiveClientMessage, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_POLL_MS, + contentLiveChannel, +} from "@/content/live/protocol"; +import { VitNodeWebSocketProvider } from "@/ws/provider"; + +import type { ContentFormTransport } from "../form/transport"; +import type { ContentLiveDraftEvent } from "./use-session"; + +import { ContentFormTransportProvider } from "../form/transport"; +import { useContentLiveSession } from "./use-session"; + +const ROOM = { contentTypeId: "test.note", itemId: 7 }; + +class FakeWebSocket { + constructor(url: string) { + this.url = url; + sockets.push(this); + } + + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = FakeWebSocket.CONNECTING; + sent: ContentLiveClientMessage[] = []; + readonly url: string; + + close() { + this.readyState = FakeWebSocket.CLOSED; + } + + drop() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + push(data: ContentLiveServerMessage) { + this.onmessage?.({ + data: JSON.stringify({ data, id: contentLiveChannel.id }), + }); + } + + send(raw: string) { + const message = JSON.parse(raw) as { data: ContentLiveClientMessage }; + this.sent.push(message.data); + } +} + +let sockets: FakeWebSocket[] = []; + +const joinsOn = (socket: FakeWebSocket | undefined) => + socket?.sent.filter(message => message.type === "join") ?? []; + +const Probe = ({ + onDraft, +}: { + onDraft?: (event: ContentLiveDraftEvent) => void; +}) => { + const session = useContentLiveSession({ ...ROOM, locale: "en" }); + + React.useEffect(() => { + if (!onDraft) return; + + return session.onDraft(onDraft); + }, [onDraft, session]); + + return ( +
+
live
+
{String(session.live)}
+
members
+
+ {session.members.map(member => member.name).join(",")} +
+
locks
+
+ {session.locks.map(lock => lock.field).join(",")} +
+
+ ); +}; + +const transportOf = () => { + const listLocks = vi.fn( + async () => await Promise.resolve({ locks: [] }), + ); + const readDraft = vi.fn( + async () => + await Promise.resolve({ drafts: { shared: null, translations: {} } }), + ); + + return { + listLocks, + readDraft, + transport: { listLocks, readDraft } as unknown as ContentFormTransport, + }; +}; + +const mount = (onDraft?: (event: ContentLiveDraftEvent) => void) => { + const fake = transportOf(); + + const view = render( + + + + + , + ); + + return { ...fake, view }; +}; + +const lockOf = (field: string) => ({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + field, + locale: null, + user: { id: 2, name: "Anna" }, +}); + +const member = (clientId: string, name: string) => ({ + avatarColor: null, + clientId, + field: null, + locale: "en", + name, + nameCode: null, + userId: 2, +}); + +describe("useContentLiveSession", () => { + describe("with a socket", () => { + beforeEach(() => { + sockets = []; + vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("BroadcastChannel", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("joins the record's room once the socket opens", () => { + mount(); + + expect(joinsOn(sockets[0])).toEqual([]); + act(() => sockets[0]?.open()); + + expect(joinsOn(sockets[0])).toEqual([ + { + clientId: expect.any(String), + locale: "en", + room: ROOM, + type: "join", + }, + ]); + }); + + it("goes live on its own joined reply, not another tab's", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId ?? ""; + + act(() => + sockets[0]?.push({ + clientId: "another-tab", + members: [], + room: ROOM, + type: "joined", + }), + ); + expect(screen.getByTestId("live").textContent).toBe("false"); + + act(() => + sockets[0]?.push({ + clientId, + members: [member(clientId, "Anna")], + room: ROOM, + type: "joined", + }), + ); + expect(screen.getByTestId("live").textContent).toBe("true"); + expect(screen.getByTestId("members").textContent).toBe("Anna"); + }); + + it("keeps only its own room's locks, presence and drafts", () => { + const onDraft = vi.fn(); + mount(onDraft); + act(() => sockets[0]?.open()); + const other = { contentTypeId: "test.note", itemId: 8 }; + + act(() => { + sockets[0]?.push({ + locks: [lockOf("body")], + room: other, + type: "locks", + }); + sockets[0]?.push({ + members: [member("x", "Ben")], + room: other, + type: "presence", + }); + sockets[0]?.push({ + by: { id: 2, name: "Anna" }, + locale: null, + room: other, + type: "draft", + updatedAt: new Date().toISOString(), + values: { title: "Elsewhere" }, + }); + }); + expect(screen.getByTestId("locks").textContent).toBe(""); + expect(screen.getByTestId("members").textContent).toBe(""); + expect(onDraft).not.toHaveBeenCalled(); + + act(() => { + sockets[0]?.push({ + locks: [lockOf("title")], + room: ROOM, + type: "locks", + }); + sockets[0]?.push({ + by: { id: 2, name: "Anna" }, + locale: null, + room: ROOM, + type: "draft", + updatedAt: new Date().toISOString(), + values: { title: "Here" }, + }); + }); + expect(screen.getByTestId("locks").textContent).toBe("title"); + expect(onDraft).toHaveBeenCalledWith( + expect.objectContaining({ values: { title: "Here" } }), + ); + }); + + it("joins again after a reconnect, since the server forgot the tab", async () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId; + + act(() => sockets[0]?.drop()); + await waitFor(() => expect(sockets).toHaveLength(2), { timeout: 4_000 }); + act(() => sockets[1]?.open()); + + expect(joinsOn(sockets[1])).toEqual([ + expect.objectContaining({ clientId, room: ROOM, type: "join" }), + ]); + }); + + it("leaves the room when the form goes away", () => { + const { transport, view } = mount(); + act(() => sockets[0]?.open()); + + // The socket outlives the form: only the form unmounts. + view.rerender( + + {null} + , + ); + + expect(sockets[0]?.sent.at(-1)).toMatchObject({ + room: ROOM, + type: "leave", + }); + }); + }); + + describe("without a socket", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls the locks and the draft over HTTP", async () => { + const onDraft = vi.fn(); + const { listLocks, readDraft } = mount(onDraft); + await waitFor(() => expect(listLocks).toHaveBeenCalledTimes(1)); + + readDraft.mockResolvedValueOnce({ + drafts: { + shared: { + baseVersion: 3, + updatedAt: "2026-10-08T12:00:00.000Z", + updatedBy: { id: 2, name: "Anna" }, + values: { title: "Polled" }, + }, + translations: {}, + }, + }); + listLocks.mockResolvedValueOnce({ locks: [lockOf("title")] }); + + await act(async () => { + vi.advanceTimersByTime(CONTENT_LIVE_POLL_MS); + await Promise.resolve(); + }); + + await waitFor(() => + expect(screen.getByTestId("locks").textContent).toBe("title"), + ); + expect(onDraft).toHaveBeenCalledWith( + expect.objectContaining({ + by: { id: 2, name: "Anna" }, + locale: null, + values: { title: "Polled" }, + }), + ); + expect(screen.getByTestId("live").textContent).toBe("false"); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.ts b/packages/vitnode/src/views/admin/views/content/live/use-session.ts new file mode 100644 index 0000000000..7694c2d6b6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.ts @@ -0,0 +1,350 @@ +import React from "react"; + +import type { ContentDrafts } from "@/content/live/http"; +import type { + ContentFieldLock, + ContentLiveMember, + ContentLiveRoomRef, + ContentLiveServerMessage, +} from "@/content/live/protocol"; + +import { + CONTENT_LIVE_HEARTBEAT_MS, + CONTENT_LIVE_POLL_MS, + contentLiveChannel, + sameContentLiveRoom, +} from "@/content/live/protocol"; +import { useVitNodeWebSocket } from "@/ws/use-websocket"; + +import { useContentFormTransport } from "../form/transport"; + +/** A change to the shared draft: from the socket, or found by polling. */ +export interface ContentLiveDraftEvent { + by: null | { id: number; name: string }; + locale: null | string; + updatedAt: string; + values: Record; +} + +export type ContentLiveResetReason = "deleted" | "restored"; + +export interface ContentLiveSession { + /** This tab. One browser shares a socket across tabs, so every message names it. */ + clientId: string; + /** Tells the room which field (and language) this tab is in. */ + focus: (field: null | string, locale: null | string) => void; + /** Whether the socket is open and the server accepted this tab into the room. */ + live: boolean; + locks: ContentFieldLock[]; + members: ContentLiveMember[]; + onDraft: (listener: (event: ContentLiveDraftEvent) => void) => () => void; + onReset: (listener: (reason: ContentLiveResetReason) => void) => () => void; + /** + * Reads every draft of the record, and from then on reports only what moved + * after it - the form's starting point, and after a reset its new one. + */ + readDrafts: () => Promise; + readyState: number; + refreshLocks: () => Promise; + /** Learns who this person is, from a lock the API granted them. */ + rememberSelf: (userId: number) => void; + /** The signed-in person's id, once known: their locks are never "someone else's". */ + self: null | number; +} + +const newClientId = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + +const draftKey = (locale: null | string): string => locale ?? ""; + +/** Every draft of a record as the events a socket would have delivered. */ +const draftEvents = (drafts: ContentDrafts): ContentLiveDraftEvent[] => [ + ...(drafts.shared + ? [ + { + by: drafts.shared.updatedBy, + locale: null, + updatedAt: drafts.shared.updatedAt, + values: drafts.shared.values, + }, + ] + : []), + ...Object.entries(drafts.translations).map(([locale, draft]) => ({ + by: draft.updatedBy, + locale, + updatedAt: draft.updatedAt, + values: draft.values, + })), +]; + +/** + * One tab's seat in a record's live editing room. + * + * Joins over the shared socket whenever it is open - again after every + * reconnect, since the server forgets a dropped tab - and keeps the seat with + * heartbeats. Without a socket (a serverless host, or one that refused the + * join) it falls back to polling the locks and the draft over HTTP, so field + * locks and autosave still work, just less instantly. + */ +export const useContentLiveSession = ({ + contentTypeId, + itemId, + locale, +}: ContentLiveRoomRef & { locale: null | string }): ContentLiveSession => { + const transport = useContentFormTransport(); + const [clientId] = React.useState(newClientId); + const room = React.useMemo( + () => ({ contentTypeId, itemId }), + [contentTypeId, itemId], + ); + + const [joined, setJoined] = React.useState(false); + const [members, setMembers] = React.useState([]); + const [locks, setLocks] = React.useState([]); + const [self, setSelf] = React.useState(null); + + const draftListenersRef = React.useRef( + new Set<(event: ContentLiveDraftEvent) => void>(), + ); + const resetListenersRef = React.useRef( + new Set<(reason: ContentLiveResetReason) => void>(), + ); + const seenDraftsRef = React.useRef(new Map()); + const focusedRef = React.useRef(null); + + const emitDraft = React.useCallback((event: ContentLiveDraftEvent) => { + seenDraftsRef.current.set(draftKey(event.locale), event.updatedAt); + for (const listener of draftListenersRef.current) listener(event); + }, []); + + const loadLocks = React.useCallback(async () => { + const result = await transport.listLocks(contentTypeId, itemId); + + return result.error === undefined ? result.locks : null; + }, [contentTypeId, itemId, transport]); + + const refreshLocks = React.useCallback(async () => { + const next = await loadLocks(); + if (next) setLocks(next); + }, [loadLocks]); + + const readDrafts = React.useCallback(async () => { + const { drafts } = await transport.readDraft(contentTypeId, itemId); + if (!drafts) return null; + + seenDraftsRef.current = new Map( + draftEvents(drafts).map(event => [ + draftKey(event.locale), + event.updatedAt, + ]), + ); + + return drafts; + }, [contentTypeId, itemId, transport]); + + /** What the draft gained since this tab last looked, as events. */ + const pollDrafts = React.useCallback(async () => { + const { drafts } = await transport.readDraft(contentTypeId, itemId); + if (!drafts) return; + + for (const event of draftEvents(drafts)) { + if ( + seenDraftsRef.current.get(draftKey(event.locale)) !== event.updatedAt + ) { + emitDraft(event); + } + } + }, [contentTypeId, emitDraft, itemId, transport]); + + const onMessage = (message: ContentLiveServerMessage) => { + switch (message.type) { + case "draft": + if (!sameContentLiveRoom(message.room, room)) return; + emitDraft({ + by: message.by, + locale: message.locale, + updatedAt: message.updatedAt, + values: message.values, + }); + + return; + case "error": + if (message.clientId !== clientId) return; + // Refused or not there: stay on HTTP polling, which re-checks the + // permission on every request anyway. + setJoined(false); + + return; + case "joined": { + if ( + message.clientId !== clientId || + !sameContentLiveRoom(message.room, room) + ) { + return; + } + setJoined(true); + setMembers(message.members); + const me = message.members.find(member => member.clientId === clientId); + if (me) setSelf(me.userId); + + return; + } + case "locks": + if (sameContentLiveRoom(message.room, room)) setLocks(message.locks); + + return; + case "presence": + if (sameContentLiveRoom(message.room, room)) { + setMembers(message.members); + } + + return; + case "reset": + if (!sameContentLiveRoom(message.room, room)) return; + for (const listener of resetListenersRef.current) { + listener(message.reason); + } + + return; + default: + // Rich text documents are not this hook's business. + return; + } + }; + + const { readyState, send } = useVitNodeWebSocket(contentLiveChannel, { + onMessage, + }); + const readyStateRef = React.useRef(readyState); + React.useEffect(() => { + readyStateRef.current = readyState; + }, [readyState]); + + // Join whenever the socket (re)opens: a reconnect is a new seat. + React.useEffect(() => { + if (readyState !== 1) return; + + send({ clientId, locale, room, type: "join" }); + + return () => { + setJoined(false); + }; + }, [clientId, locale, readyState, room, send]); + + // Leave on unmount, on a room change, and when the page goes away. + React.useEffect(() => { + const leave = () => { + send({ clientId, room, type: "leave" }); + }; + window.addEventListener("pagehide", leave); + + return () => { + window.removeEventListener("pagehide", leave); + leave(); + }; + }, [clientId, room, send]); + + const live = joined && readyState === 1; + + // Every (re)join: whatever moved while this tab was not in the room was + // never delivered, and the room has forgotten where this tab was. + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- `joined` arrives over the socket; this resyncs once per seat + if (!live) return; + + void loadLocks().then(next => { + if (next) setLocks(next); + }); + void pollDrafts(); + if (focusedRef.current) { + send({ clientId, room, type: "focus", ...focusedRef.current }); + } + }, [clientId, live, loadLocks, pollDrafts, room, send]); + + React.useEffect(() => { + if (!live) return; + + const timer = setInterval(() => { + send({ clientId, room, type: "heartbeat" }); + }, CONTENT_LIVE_HEARTBEAT_MS); + + return () => { + clearInterval(timer); + }; + }, [clientId, live, room, send]); + + // The first look at the locks, whatever the socket is doing. + React.useEffect(() => { + void loadLocks().then(next => { + if (next) setLocks(next); + }); + }, [loadLocks]); + + // No socket: HTTP is the only way to hear about other people. + React.useEffect(() => { + if (live) return; + + const timer = setInterval(() => { + void loadLocks().then(next => { + if (next) setLocks(next); + }); + void pollDrafts(); + }, CONTENT_LIVE_POLL_MS); + + return () => { + clearInterval(timer); + }; + }, [live, loadLocks, pollDrafts]); + + const focus = React.useCallback( + (field: null | string, fieldLocale: null | string) => { + focusedRef.current = { field, locale: fieldLocale }; + if (readyStateRef.current !== 1) return; + + send({ clientId, field, locale: fieldLocale, room, type: "focus" }); + }, + [clientId, room, send], + ); + + const onDraft = React.useCallback( + (listener: (event: ContentLiveDraftEvent) => void) => { + draftListenersRef.current.add(listener); + + return () => { + draftListenersRef.current.delete(listener); + }; + }, + [], + ); + + const onReset = React.useCallback( + (listener: (reason: ContentLiveResetReason) => void) => { + resetListenersRef.current.add(listener); + + return () => { + resetListenersRef.current.delete(listener); + }; + }, + [], + ); + + return { + clientId, + focus, + live, + locks, + members, + onDraft, + onReset, + readDrafts, + readyState, + refreshLocks, + rememberSelf: setSelf, + self, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/values.test.ts b/packages/vitnode/src/views/admin/views/content/live/values.test.ts new file mode 100644 index 0000000000..ce2cde522e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/values.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentDrafts } from "@/content/live/http"; + +import { + contentDraftDiffers, + mergeContentDraft, + overlayContentDrafts, +} from "./values"; + +const draft = (values: Record) => ({ + baseVersion: 1, + updatedAt: "2026-10-08T12:00:00.000Z", + updatedBy: { id: 2, name: "Anna" }, + values, +}); + +const drafts: ContentDrafts = { + shared: draft({ featured: true }), + translations: { + de: draft({ title: "Hallo" }), + pl: draft({ title: "Cześć" }), + }, +}; + +const record = { featured: false, id: 7, labels: {} }; +const translations = [ + { locale: "en", values: { body: "Body", title: "Hello" } }, + { locale: "pl", values: { body: "Treść", title: "Witaj" } }, +]; + +describe("overlayContentDrafts", () => { + it("opens the form on the draft, shared and per language", () => { + const overlaid = overlayContentDrafts(record, translations, drafts); + + expect(overlaid.data).toMatchObject({ featured: true, id: 7 }); + expect(overlaid.translations).toEqual([ + { locale: "en", values: { body: "Body", title: "Hello" } }, + { locale: "pl", values: { body: "Treść", title: "Cześć" } }, + // A language drafted before it was ever saved. + { locale: "de", values: { title: "Hallo" } }, + ]); + }); + + it("leaves the committed record alone", () => { + overlayContentDrafts(record, translations, drafts); + + expect(record.featured).toBe(false); + expect(translations[1]?.values.title).toBe("Witaj"); + }); +}); + +describe("contentDraftDiffers", () => { + it("is true while the draft holds what the record does not", () => { + expect(contentDraftDiffers(record, translations, drafts)).toBe(true); + }); + + it("is false once Save committed the drafted values", () => { + expect( + contentDraftDiffers( + { ...record, featured: true }, + [ + ...translations.slice(0, 1), + { locale: "pl", values: { body: "Treść", title: "Cześć" } }, + { locale: "de", values: { title: "Hallo" } }, + ], + drafts, + ), + ).toBe(false); + }); +}); + +describe("mergeContentDraft", () => { + it("folds a change into the language it belongs to", () => { + const merged = mergeContentDraft(drafts, { + by: { id: 3, name: "Ben" }, + locale: "pl", + updatedAt: "2026-10-08T12:01:00.000Z", + values: { body: "Nowa treść" }, + }); + + expect(merged.translations.pl).toEqual({ + baseVersion: 1, + updatedAt: "2026-10-08T12:01:00.000Z", + updatedBy: { id: 3, name: "Ben" }, + values: { body: "Nowa treść", title: "Cześć" }, + }); + expect(merged.shared).toBe(drafts.shared); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/values.ts b/packages/vitnode/src/views/admin/views/content/live/values.ts new file mode 100644 index 0000000000..3a2262b841 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/values.ts @@ -0,0 +1,136 @@ +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentDrafts } from "@/content/live/http"; + +import { isReferenceKind } from "@/content/admin/spec"; + +import type { ContentLiveDraftEvent } from "./use-session"; + +interface TranslationValues { + locale: string; + values: Record; +} + +const same = (a: unknown, b: unknown): boolean => + JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + +/** The single-reference fields of the shared draft that point somewhere new. */ +export const contentDraftMovedReferences = ( + spec: ContentFormSpec, + data: Record, + drafts: ContentDrafts | null, +): { field: string; id: number }[] => + Object.entries(drafts?.shared?.values ?? {}).flatMap(([name, value]) => { + const fieldSpec = spec.fields.find(field => field.name === name); + if ( + !fieldSpec || + fieldSpec.multiple === true || + !isReferenceKind(fieldSpec.kind) || + typeof value !== "number" || + same(value, data[name]) + ) { + return []; + } + + return [{ field: name, id: value }]; + }); + +/** + * The record and its translations with the shared draft laid over them - the + * values the form opens on. Only what the form *shows* moves: the record the + * form compares a Save against stays the committed one, so every drafted value + * still counts as a change. + */ +export const overlayContentDrafts = < + TData extends Record, + TTranslation extends TranslationValues, +>( + data: TData, + translations: readonly TTranslation[], + drafts: ContentDrafts | null, + labels: Record = {}, +): { data: TData; translations: TranslationValues[] } => { + if (!drafts) return { data, translations: [...translations] }; + + const overlaid: TData = drafts.shared + ? { + ...data, + ...drafts.shared.values, + labels: { + ...(data.labels as Record | undefined), + ...labels, + }, + } + : data; + + const rows: TranslationValues[] = translations.map(row => { + const draft = drafts.translations[row.locale]; + + return draft ? { ...row, values: { ...row.values, ...draft.values } } : row; + }); + for (const [locale, draft] of Object.entries(drafts.translations)) { + if (!rows.some(row => row.locale === locale)) { + rows.push({ locale, values: draft.values }); + } + } + + return { data: overlaid, translations: rows }; +}; + +/** One more draft change folded into what this tab knows of the draft. */ +export const mergeContentDraft = ( + drafts: ContentDrafts | null, + event: ContentLiveDraftEvent, +): ContentDrafts => { + const current = drafts ?? { shared: null, translations: {} }; + const previous = + event.locale === null ? current.shared : current.translations[event.locale]; + const next = { + baseVersion: previous?.baseVersion ?? 0, + updatedAt: event.updatedAt, + updatedBy: event.by ?? previous?.updatedBy ?? null, + values: { ...previous?.values, ...event.values }, + }; + + return event.locale === null + ? { ...current, shared: next } + : { + ...current, + translations: { ...current.translations, [event.locale]: next }, + }; +}; + +/** Whether the draft holds a value the committed record does not. */ +export const contentDraftDiffers = ( + data: Record, + translations: readonly TranslationValues[], + drafts: ContentDrafts | null, +): boolean => { + if (!drafts) return false; + + const sharedDiffers = Object.entries(drafts.shared?.values ?? {}).some( + ([name, value]) => !same(value, data[name]), + ); + if (sharedDiffers) return true; + + return Object.entries(drafts.translations).some(([locale, draft]) => { + const row = translations.find(entry => entry.locale === locale); + + return Object.entries(draft.values).some( + ([name, value]) => !same(value, row?.values[name]), + ); + }); +}; + +/** The value a draft holds for one field in one language, if any. */ +export const contentDraftValue = ( + drafts: ContentDrafts | null, + field: string, + locale: null | string, +): undefined | { value: unknown } => { + const values = + locale === null + ? drafts?.shared?.values + : drafts?.translations[locale]?.values; + + return values && field in values ? { value: values[field] } : undefined; +}; From 380110deccef9c0afe654cb43890250bcb4b6cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:52:04 +0000 Subject: [PATCH 05/25] =?UTF-8?q?feat(content):=20=E2=9C=A8=20clean=20up?= =?UTF-8?q?=20stale=20field=20locks,=20drafts=20and=20documents=20daily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../cron/content-editorial-cleanup.cron.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 index 30ac6ac209..2863b65b74 100644 --- 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 @@ -3,6 +3,8 @@ import { CONTENT_REVISION_MAX_RETENTION, CONTENT_SCHEDULE_RETENTION_DAYS, } from "@/content/const"; +import { cleanupStaleContentDrafts } from "@/content/server/live-store"; +import { cleanupStaleContentDocuments } from "@/content/server/live/document-store"; import { pruneContentRevisions } from "@/content/server/revisions-model"; import { pruneContentSchedules } from "@/content/server/schedules-model"; @@ -11,7 +13,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; 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.", + "Remove revisions and schedules for content types that are no longer registered, settled schedules past their retention window, expired field locks, and live-editing drafts and documents nobody touched for 30 days.", // 03:20 daily, off the hour so it does not pile onto every other daily job. schedule: "20 3 * * *", handler: async c => { @@ -33,6 +35,17 @@ export const contentEditorialCleanupCron = buildCron({ knownContentTypeIds: known, }); + const live = await cleanupStaleContentDrafts(c); + const documents = await cleanupStaleContentDocuments(c); + + if (live.drafts + live.locks + documents > 0) { + await c + .get("log") + .debug( + `[content-editorial-cleanup] removed ${live.locks} expired field locks, ${live.drafts} stale drafts and ${documents} stale documents.`, + ); + } + if (schedules.orphaned + revisions.orphaned === 0) return; // Worth saying out loud: an unexpected number here usually means a plugin From a2a7aaaeb8866a87942f37eac20aa205370a6c2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:54:09 +0000 Subject: [PATCH 06/25] =?UTF-8?q?test(content):=20=E2=9C=85=20cover=20fiel?= =?UTF-8?q?d=20locks=20and=20draft=20merges=20against=20Postgres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../server/live-store.integration.test.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 packages/vitnode/src/content/server/live-store.integration.test.ts diff --git a/packages/vitnode/src/content/server/live-store.integration.test.ts b/packages/vitnode/src/content/server/live-store.integration.test.ts new file mode 100644 index 0000000000..085072f4de --- /dev/null +++ b/packages/vitnode/src/content/server/live-store.integration.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment node +import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; + +import type { ContentLiveRoomRef } from "@/content/live/protocol"; + +import { + core_content_drafts, + core_content_field_locks, +} from "@/database/content-drafts"; +import { core_files } from "@/database/files"; +import { core_languages } from "@/database/languages"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { + createTestDatabase, + describePostgres, + type TestDatabaseHandle, +} from "@/tests/postgres"; + +import { + acquireContentFieldLock, + findContentFieldLock, + heldContentFieldLocks, + readContentDrafts, + writeContentDraft, +} from "./live-store"; + +const room: ContentLiveRoomRef = { contentTypeId: "test.guide", itemId: 1 }; +const title = { ...room, field: "title", locale: "en" }; + +describePostgres("the field lock and draft store", () => { + let database: TestDatabaseHandle; + let anna: number; + let marek: number; + + beforeAll(async () => { + database = await createTestDatabase({ + core_content_drafts, + core_content_field_locks, + core_files, + core_languages, + core_roles, + core_users, + }); + + await database.db + .insert(core_languages) + .values({ code: "en", name: "English", timezone: "UTC" }); + await database.db.insert(core_roles).values({ id: 1 }); + const users = await database.db + .insert(core_users) + .values( + ["anna", "marek"].map(name => ({ + avatarColor: "000000", + email: `${name}@example.com`, + ipAddress: "127.0.0.1", + name, + nameCode: name, + roleId: 1, + })), + ) + .returning({ id: core_users.id }); + [anna, marek] = users.map(user => user.id); + }); + + afterAll(async () => { + await database?.drop(); + }); + + beforeEach(async () => { + await database.db.delete(core_content_field_locks); + await database.db.delete(core_content_drafts); + }); + + it("lets exactly one of two racing editors take a free field", async () => { + const results = await Promise.all([ + acquireContentFieldLock(database.connect(), title, anna), + acquireContentFieldLock(database.connect(), title, marek), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + }); + + it("refuses a held lease, and hands over an expired one", async () => { + const start = new Date("2026-10-08T10:00:00Z"); + + expect(await acquireContentFieldLock(database.db, title, anna, start)).toBe( + true, + ); + expect( + await acquireContentFieldLock(database.db, title, marek, start), + ).toBe(false); + // The holder re-acquiring is a renewal, not a conflict. + expect(await acquireContentFieldLock(database.db, title, anna, start)).toBe( + true, + ); + + const later = new Date(start.getTime() + 61_000); + + expect( + await acquireContentFieldLock(database.db, title, marek, later), + ).toBe(true); + expect( + (await findContentFieldLock(database.db, title, later))?.user.id, + ).toBe(marek); + }); + + it("only counts the caller's unexpired locks in that language", async () => { + await acquireContentFieldLock(database.db, title, anna); + await acquireContentFieldLock( + database.db, + { ...title, field: "excerpt" }, + marek, + ); + + const held = await heldContentFieldLocks(database.db, { + locale: "en", + names: ["title", "excerpt"], + room, + userId: anna, + }); + + expect([...held]).toEqual(["title"]); + }); + + it("merges concurrent draft writes of different fields", async () => { + await Promise.all([ + writeContentDraft(database.connect(), { + baseVersion: 3, + locale: "en", + room, + userId: anna, + values: { title: "Hello" }, + }), + writeContentDraft(database.connect(), { + baseVersion: 3, + locale: "en", + room, + userId: marek, + values: { excerpt: "Short" }, + }), + ]); + await writeContentDraft(database.db, { + baseVersion: 3, + locale: null, + room, + userId: anna, + values: { categoryId: [1, 2] }, + }); + + const drafts = await readContentDrafts(database.db, room); + + expect(drafts.translations.en.values).toEqual({ + excerpt: "Short", + title: "Hello", + }); + expect(drafts.shared?.values).toEqual({ categoryId: [1, 2] }); + }); + + it("overwrites a field's earlier draft value", async () => { + const write = async (value: string) => + await writeContentDraft(database.db, { + baseVersion: 1, + locale: "en", + room, + userId: anna, + values: { title: value }, + }); + + await write("First"); + await write("Second"); + + expect( + (await readContentDrafts(database.db, room)).translations.en, + ).toMatchObject({ updatedBy: { id: anna }, values: { title: "Second" } }); + }); +}); From 4c9de650e53562c6e2aa036bb16a9a7b7aef591f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:55:11 +0000 Subject: [PATCH 07/25] =?UTF-8?q?feat(content):=20=E2=9C=A8=20add=20richTe?= =?UTF-8?q?xt=20field=20with=20SSR=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - field.richText(): ProseMirror JSON in a jsonb column, localizable, validated by a recursive Zod schema with depth and size limits - RichTextContent (@vitnode/core/components/rich-text): pure React renderer with link/style allowlists and node/mark overrides, never reaching Tiptap, ProseMirror or Yjs - richTextToPlainText, richTextToHtml, isRichTextEmpty for search, SEO, revision diffs and AI prompts - Tiptap editor JSON mode (format="json"), plus extensions/undoRedo props - Blog article content switched to rich text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../docs/dev/content-engine/fields.mdx | 19 + .../docs/dev/content-engine/localization.mdx | 2 +- .../content/docs/dev/content-engine/meta.json | 1 + .../docs/dev/content-engine/reference.mdx | 12 +- .../docs/dev/content-engine/rich-text.mdx | 122 +++++ packages/vitnode/package.json | 10 + .../src/components/form/fields/editor.tsx | 218 +++++++-- .../src/components/form/fields/multi-lang.tsx | 62 ++- .../components/rich-text/boundaries.test.ts | 59 +++ .../vitnode/src/components/rich-text/index.ts | 20 + .../rich-text/rich-text-content.test.tsx | 379 +++++++++++++++ .../rich-text/rich-text-content.tsx | 170 +++++++ .../src/components/tiptap/extension.ts | 4 + .../src/components/tiptap/rich-text-json.ts | 134 ++++++ .../src/components/tiptap/tiptap-editor.tsx | 89 +++- packages/vitnode/src/content/admin/spec.ts | 97 +++- packages/vitnode/src/content/const.ts | 21 +- packages/vitnode/src/content/define-fields.ts | 18 + packages/vitnode/src/content/define-shared.ts | 6 +- packages/vitnode/src/content/delivery.ts | 7 +- packages/vitnode/src/content/fields.ts | 24 + packages/vitnode/src/content/index.ts | 20 + .../vitnode/src/content/localization.test.ts | 4 +- packages/vitnode/src/content/revisions.ts | 4 +- .../src/content/rich-text-field.test.ts | 302 ++++++++++++ .../src/content/rich-text/document.test.ts | 209 +++++++++ .../vitnode/src/content/rich-text/document.ts | 222 +++++++++ .../vitnode/src/content/rich-text/elements.ts | 438 ++++++++++++++++++ .../vitnode/src/content/rich-text/html.ts | 90 ++++ .../vitnode/src/content/rich-text/index.ts | 38 ++ .../src/content/rich-text/plain-text.ts | 65 +++ .../src/content/rich-text/text.test.ts | 214 +++++++++ packages/vitnode/src/content/schemas.ts | 19 + packages/vitnode/src/content/search.test.ts | 2 +- .../src/content/server/column-builders.ts | 6 + packages/vitnode/src/content/server/query.ts | 15 +- .../src/content/server/revision-snapshot.ts | 13 +- .../src/content/server/search-document.ts | 10 +- packages/vitnode/src/content/server/types.ts | 7 +- packages/vitnode/src/content/types.ts | 21 +- .../vitnode/src/lib/helpers/multi-lang.ts | 58 ++- .../views/content/actions/conflict-notice.tsx | 3 + .../content/actions/history/revision-diff.tsx | 43 +- .../admin/views/content/form/skeleton.tsx | 1 + .../views/content/lib/field-component.tsx | 24 + .../views/admin/views/content/table/cells.tsx | 9 + plugins/blog/src/api/ai/actions.ts | 58 ++- plugins/blog/src/api/lib/ai-writing.ts | 5 +- .../modules/admin/ai/ai.admin.module.test.ts | 63 ++- .../admin/ai/routes/translate.route.ts | 3 +- plugins/blog/src/content/post.ts | 4 +- plugins/blog/src/pages/post-page.tsx | 16 +- .../src/views/admin/article/editor-field.tsx | 1 + .../blog/src/views/admin/article/editor/ai.ts | 35 +- .../admin/article/editor/article-editor.tsx | 60 ++- .../admin/article/editor/content-html.ts | 14 + .../admin/article/editor/publish-panel.tsx | 10 +- .../admin/article/editor/readiness.test.ts | 46 +- .../views/admin/article/editor/readiness.ts | 31 +- 59 files changed, 3460 insertions(+), 197 deletions(-) create mode 100644 apps/web/content/docs/dev/content-engine/rich-text.mdx create mode 100644 packages/vitnode/src/components/rich-text/boundaries.test.ts create mode 100644 packages/vitnode/src/components/rich-text/index.ts create mode 100644 packages/vitnode/src/components/rich-text/rich-text-content.test.tsx create mode 100644 packages/vitnode/src/components/rich-text/rich-text-content.tsx create mode 100644 packages/vitnode/src/components/tiptap/rich-text-json.ts create mode 100644 packages/vitnode/src/content/rich-text-field.test.ts create mode 100644 packages/vitnode/src/content/rich-text/document.test.ts create mode 100644 packages/vitnode/src/content/rich-text/document.ts create mode 100644 packages/vitnode/src/content/rich-text/elements.ts create mode 100644 packages/vitnode/src/content/rich-text/html.ts create mode 100644 packages/vitnode/src/content/rich-text/index.ts create mode 100644 packages/vitnode/src/content/rich-text/plain-text.ts create mode 100644 packages/vitnode/src/content/rich-text/text.test.ts create mode 100644 plugins/blog/src/views/admin/article/editor/content-html.ts diff --git a/apps/web/content/docs/dev/content-engine/fields.mdx b/apps/web/content/docs/dev/content-engine/fields.mdx index 5bee8c2ab4..6591fc3743 100644 --- a/apps/web/content/docs/dev/content-engine/fields.mdx +++ b/apps/web/content/docs/dev/content-engine/fields.mdx @@ -22,6 +22,7 @@ fields: { | ------------------- | ---------------------------------------- | --------------------------------- | ------------------ | | `field.text()` | `varchar(maxLength)`, 255 by default | Text input | Yes | | `field.textarea()` | `text` | Textarea | Yes | +| `field.richText()` | `jsonb` | Rich text editor | Yes | | `field.number()` | `integer` or `double precision` | Number input with steppers | No | | `field.boolean()` | `boolean` | Switch | No | | `field.enum()` | `varchar(length)`, 64 by default | Select, or radio buttons | No | @@ -64,6 +65,24 @@ A single `field.user()` and a single `field.file()` are nullable unless you say Set `maxLength` on every `text` field. Without it, validation accepts any length and Postgres rejects values over 255 characters with an error. +## Rich text + +`field.richText()` stores formatted text - headings, lists, links, tables - as a ProseMirror JSON document in a `jsonb` column. The AdminCP edits it with the Tiptap editor, and your public page renders it with `RichTextContent`, which never downloads the editor. + +```ts +content: field.richText({ localized: true, required: true }), +``` + +| Option | Type | Default | Description | +| ------------- | --------- | --------- | --------------------------------------------------------------------------- | +| `required` | `boolean` | `false` | The document must have content. `{ type: "doc" }` with an empty paragraph is refused | +| `nullable` | `boolean` | `false` | The column accepts `NULL` | +| `maxBytes` | `number` | 1 MB | Largest document, measured as UTF-8 JSON. Up to 16 MB | +| `localized` | `boolean` | `false` | Stores one document per language | +| `description` | `string` | | Shown under the editor | + +A rich text field has no `defaultValue`, so set `required` or `nullable`. Validation also refuses documents nested deeper than 64 levels. [Rich text](/docs/dev/content-engine/rich-text) covers rendering and the helpers for plain text and HTML. + ## Number | Option | Type | Default | Description | diff --git a/apps/web/content/docs/dev/content-engine/localization.mdx b/apps/web/content/docs/dev/content-engine/localization.mdx index 247a6114cf..12ca4d3e78 100644 --- a/apps/web/content/docs/dev/content-engine/localization.mdx +++ b/apps/web/content/docs/dev/content-engine/localization.mdx @@ -36,7 +36,7 @@ export const recipeContentType = defineContentType({ }); ``` -`text`, `textarea`, `slug`, `blocks` and whole `group` fields can be localized. Numbers, booleans, enums, dates, files, users and relations are always shared. A slug and its `source` must both be localized or both shared. +`text`, `textarea`, `richText`, `slug`, `blocks` and whole `group` fields can be localized. Numbers, booleans, enums, dates, files, users and relations are always shared. A slug and its `source` must both be localized or both shared. diff --git a/apps/web/content/docs/dev/content-engine/meta.json b/apps/web/content/docs/dev/content-engine/meta.json index 478d4978a6..2c377d1232 100644 --- a/apps/web/content/docs/dev/content-engine/meta.json +++ b/apps/web/content/docs/dev/content-engine/meta.json @@ -19,6 +19,7 @@ "---Reference---", "reference", "fields", + "rich-text", "database-and-migrations", "plugin-registration" ] diff --git a/apps/web/content/docs/dev/content-engine/reference.mdx b/apps/web/content/docs/dev/content-engine/reference.mdx index 70a7d16bbf..841e48b5d9 100644 --- a/apps/web/content/docs/dev/content-engine/reference.mdx +++ b/apps/web/content/docs/dev/content-engine/reference.mdx @@ -68,7 +68,7 @@ To turn a feature off, leave its block out. The `enabled` keys accept only `true | `create.mode`, `edit.mode` | `"dialog"` \| `"page"` | `"dialog"` | Open the form in a dialog or on its own page | | `navigation.enabled` | `boolean` | `true` | `false` hides the sidebar entry | | `list.columns` | field names | `status` (with publication), shared columns, `updatedAt` | Table columns, in order | -| `list.searchableFields` | text, textarea or slug fields | shared text and textarea fields | Fields the search box matches. Localized fields match in any language | +| `list.searchableFields` | text, textarea, richText or slug fields | shared text and textarea fields | Fields the search box matches. Localized fields match in any language | | `list.orderableFields` | shared scalar fields | `[]` | Sortable fields you declared. `id`, `createdAt`, `updatedAt`, `status`, `publishedAt` and `version` are always sortable and must not be listed | | `list.defaultOrderBy` | column name | `"updatedAt"` | Must be a system column or in `orderableFields` | | `list.defaultOrder` | `"asc"` \| `"desc"` | `"desc"` | | @@ -85,7 +85,7 @@ How these options look on screen is covered in [Customize the AdminCP](/docs/dev | `enabled` | `true` | | Required | | `path` | `string` | | One lowercase segment, at most 64 characters, not `admin`, unique within the plugin. Routes live at `/api/{pluginId}/content/{path}` | | `fields` | field names | | The allowlist. Exactly one top-level slug field. May include `id`, `createdAt`, `updatedAt` and `publishedAt`. Not `status`, not `field.user()`; list group leaves as `"seo.title"` | -| `searchableFields` | exposed fields | `[]` | Text, textarea or slug fields matched by `?search=` | +| `searchableFields` | exposed fields | `[]` | Text, textarea, richText or slug fields matched by `?search=`. Rich text matches its words only | | `filterableFields` | exposed fields | `[]` | Boolean, enum, number, relation, slug or text fields, each its own query parameter | | `orderableFields` | exposed fields | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added. No localized, file or to-many fields | | `defaultOrderBy` | column name | `"publishedAt"` | | @@ -100,8 +100,8 @@ How these options look on screen is covered in [Customize the AdminCP](/docs/dev | `redirects.enabled` | `boolean` | `false` | Old slugs answer `308` to the current URL | | `seo.titleField` | text field | | Page title | | `seo.fallbackTitleField` | text field | | Used when `titleField` is empty | -| `seo.descriptionField` | text or textarea field | | Meta description, stripped of HTML and cut to 160 characters | -| `seo.fallbackDescriptionField` | text or textarea field | | Used when `descriptionField` is empty | +| `seo.descriptionField` | text, textarea or richText field | | Meta description, stripped of HTML and cut to 160 characters | +| `seo.fallbackDescriptionField` | text, textarea or richText field | | Used when `descriptionField` is empty | | `seo.noIndexField` | shared boolean field | | `true` marks the page `noindex` and leaves it out of the sitemap | | `seo.openGraph` | `{ titleField?, descriptionField? }` | | | | `sitemap.enabled` | `boolean` | `false` | Lists published URLs at `/delivery/sitemap` | @@ -136,9 +136,9 @@ Every SEO field must be in `publicApi.fields`. A localized type also needs `"id" | ------------------ | ---------------- | ------------------------------------------------------------------------------- | | `enabled` | `true` | Required | | `titleField` | text field | Required. Not nullable, and in `publicApi.fields` | -| `contentFields` | field names | Required. Slug, text or textarea fields that are public | +| `contentFields` | field names | Required. Slug, text, textarea or richText fields that are public | | `pathTemplate` | `string` | Required. The result URL with exactly one `{slug}`, such as `/recipes/{slug}`. A page must serve it | -| `descriptionField` | text or textarea | Optional, and public | +| `descriptionField` | text, textarea or richText | Optional, and public | | `authorField` | `field.user()` | Optional. Credits people in the index; it does not have to be public | Drafts are indexed as private documents; public search returns only published records. diff --git a/apps/web/content/docs/dev/content-engine/rich-text.mdx b/apps/web/content/docs/dev/content-engine/rich-text.mdx new file mode 100644 index 0000000000..cc930688cc --- /dev/null +++ b/apps/web/content/docs/dev/content-engine/rich-text.mdx @@ -0,0 +1,122 @@ +--- +title: Rich text +description: Store formatted Content Engine text as ProseMirror JSON with field.richText, render it on the server with RichTextContent, and turn it into plain text or HTML. +icon: Pilcrow +--- + +`field.richText()` is the Content Engine field for formatted text: headings, lists, links, tables, task lists and panels. Editors write it in the AdminCP with the Tiptap editor. Readers get it as plain HTML rendered by `RichTextContent`, a small React component that never loads the editor, so the public page stays light. Your readers came for the article, not for an editor they will never type in. + +## Define a rich text field + +Add the field to a content type. A blog article body is the typical case: + +```ts title="plugins/blog/src/content/post.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const blogPostContentType = defineContentType({ + // id, tableName, localization, publicApi as before + fields: { + title: field.text({ localized: true, required: true, maxLength: 255 }), + content: field.richText({ localized: true, required: true }), // [!code highlight] + }, +}); +``` + +The value is a ProseMirror document, the same JSON Tiptap's `editor.getJSON()` returns: + +```json +{ + "type": "doc", + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Hello!" }] } + ] +} +``` + +The column is `jsonb`. Every write is validated: the shape must be a document, it can nest at most 64 levels deep, and its JSON must fit in `maxBytes` (1 MB by default). With `required: true`, a document without any text or media is refused, so an empty editor cannot be saved. All options are in [Fields](/docs/dev/content-engine/fields#rich-text). + +The AdminCP form shows the editor automatically. It is loaded lazily, so forms without a rich text field never download it. + +## Render it on a public page + +Import `RichTextContent` from `@vitnode/core/components/rich-text` and pass the document. It works during SSR and in the browser: + +```tsx title="plugins/blog/src/pages/post-page.tsx" +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { zodRichTextDocument } from "@vitnode/core/content"; +import { z } from "zod"; + +const zodBlogPost = z.object({ + content: zodRichTextDocument, + title: z.string(), +}); + +const BlogPostPage = ({ item }: { item: z.infer }) => ( +
+

{item.title}

+ +
+); +``` + +The output is wrapped in `
` and uses the same classes as the editor (`list-disc`, `tableWrapper`, `tiptap-panel` and so on), so the existing `@vitnode/core/styles/tiptap.css` styles it. + +`RichTextContent` treats the document as untrusted input: + +- Links keep only `http:`, `https:`, `mailto:`, `tel:`, relative and `#` targets. A `javascript:` or `data:` link renders as plain text. +- A link that opens a new tab gets `rel="noopener noreferrer nofollow"`. +- Styles come from an allowlist: text colors, font sizes with a unit, and text alignment. +- An unknown node renders its children; an unknown mark renders plain text. + +## Add or replace a renderer + +Pass `nodes` or `marks` to change how one type renders. The keys are ProseMirror type names, and each renderer receives the node (or mark) and its already rendered children: + +```tsx + ( + {children} + ), + }} + nodes={{ + heading: ({ children, node }) => + node.attrs?.level === 2 ?

{children}

:

{children}

, + }} +/> +``` + +A custom renderer is your code, so it skips the built-in link and style allowlists. Sanitize anything it reads from `attrs`; `sanitizeRichTextHref` from the same import does links. + +## Get plain text or HTML + +Three helpers work on the server and in the browser without React. Import them from `@vitnode/core/content`: + +| Helper | Returns | Use it for | +| -------------------------- | --------- | -------------------------------------------------------------------------- | +| `richTextToPlainText(doc)` | `string` | Search, excerpts, AI prompts, word counts. One line per block, no markup | +| `richTextToHtml(doc)` | `string` | Emails and feeds. Escaped, with the same allowlists as `RichTextContent` | +| `isRichTextEmpty(doc)` | `boolean` | Checking whether a document has any text or media | + +```ts +import { richTextToPlainText } from "@vitnode/core/content"; + +const words = richTextToPlainText(post.content).split(/\s+/).length; +``` + +The Content Engine uses them for you: the site search indexes the words of a rich text field, `delivery.seo.descriptionField` can name one and gets plain text, and the revision history compares documents as text. + +## Use the editor in your own form + +Outside the Content Engine, `AutoFormEditor` from `@vitnode/core/components/form/fields/editor` stores HTML by default. Add `format="json"` to store a document instead: + +```tsx + +``` + +## Check the result + +Open an article in **AdminCP**, write a heading and a list, and save. The public page shows the same content, and the browser's network tab shows no `@tiptap` or `prosemirror` chunk for that page. + +Several editors can work on the same rich text field at once; [Live editing](/docs/dev/content-engine/live-editing) covers co-editing and presence. diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 1429523a86..3b9ee5f49a 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -114,6 +114,16 @@ "types": "./dist/src/content/server/index.d.ts", "default": "./dist/src/content/server/index.js" }, + "./content/rich-text": { + "import": "./dist/src/content/rich-text/index.js", + "types": "./dist/src/content/rich-text/index.d.ts", + "default": "./dist/src/content/rich-text/index.js" + }, + "./components/rich-text": { + "import": "./dist/src/components/rich-text/index.js", + "types": "./dist/src/components/rich-text/index.d.ts", + "default": "./dist/src/components/rich-text/index.js" + }, "./content/editor": { "import": "./dist/src/content/editor/index.js", "types": "./dist/src/content/editor/index.d.ts", diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx index 857642ea90..bb6fc1a6e5 100644 --- a/packages/vitnode/src/components/form/fields/editor.tsx +++ b/packages/vitnode/src/components/form/fields/editor.tsx @@ -1,7 +1,13 @@ import type React from "react"; +import type { TipTapEditorBaseProps } from "@/components/tiptap/tiptap-editor"; +import type { FormFieldApi } from "@/components/ui/form"; +import type { RichTextDocument } from "@/content/rich-text/document"; +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + import { Editor } from "@/components/ui/editor"; import { FormControl, FormMessage, useFormField } from "@/components/ui/form"; +import { isRichTextEmpty } from "@/content/rich-text/document"; import { stripHtml } from "@/lib/strip-html"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -12,11 +18,16 @@ import { type MultiLangFieldProps, MultiLangSelect, useMultiLangField, + useMultiLangValueField, } from "./multi-lang"; import { MultiLangSelectedContext } from "./multi-lang-language"; const hasHtmlText = (html: string): boolean => stripHtml(html) !== ""; +const hasDocumentContent = ( + value: null | RichTextDocument | undefined, +): boolean => !isRichTextEmpty(value); + const useEditorLabelledBy = (label: React.ReactNode): string | undefined => { const { formItemId } = useFormField(); @@ -24,24 +35,79 @@ const useEditorLabelledBy = (label: React.ReactNode): string | undefined => { }; type AutoFormEditorProps = ItemAutoFormComponentProps & - Omit, "onChange" | "value"> & { + TipTapEditorBaseProps & { + /** + * `html` (the default) stores an HTML string. `json` stores a ProseMirror + * document - what a Content Engine `field.richText()` holds. + */ + format?: "html" | "json"; multiLang?: boolean; }; -const MultiLangEditor = ({ +type MultiLangEditorProps = Omit< + AutoFormEditorProps, + "field" | "format" | "itemParams" | "multiLang" | "otherProps" +> & { + field: TField; + isOptional?: boolean; +}; + +const MultiLangEditorLayout = ({ + canSelect, + children, + description, + isOptional, + label, + labelRight, + languages, + onSelect, + selected, +}: { + canSelect: boolean; + children: React.ReactNode; + description?: React.ReactNode; + isOptional?: boolean; + label?: React.ReactNode; + labelRight?: React.ReactNode; + languages: React.ComponentProps["languages"]; + onSelect: (code: string) => void; + selected: string; +}) => ( + <> +
+ {!!label && ( + + {label} + + )} + {canSelect && ( + + )} +
+ + + + {children} + + + + {!!description && {description}} + + +); + +const MultiLangHtmlEditor = ({ label, labelRight, description, isOptional, field, ...props -}: MultiLangFieldProps & - Omit< - AutoFormEditorProps, - "field" | "itemParams" | "multiLang" | "otherProps" - > & { - isOptional?: boolean; - }) => { +}: MultiLangEditorProps) => { const { canSelect, languages, @@ -53,38 +119,71 @@ const MultiLangEditor = ({ const labelledBy = useEditorLabelledBy(label); return ( - <> -
- {!!label && ( - - {label} - - )} - {canSelect && ( - - )} -
+ + + + ); +}; - - - - - +const MultiLangJsonEditor = ({ + label, + labelRight, + description, + isOptional, + field, + ...props +}: MultiLangEditorProps< + FormFieldApi | undefined> +>) => { + const { + canSelect, + languages, + selected, + setSelected, + currentValue, + setValue, + } = useMultiLangValueField(field, { + isFilled: hasDocumentContent, + }); + const labelledBy = useEditorLabelledBy(label); - {!!description && {description}} - - + return ( + + + ); }; @@ -94,6 +193,7 @@ export const AutoFormEditor = ({ description, otherProps: { isOptional }, field, + format = "html", // oxlint-disable-next-line no-unused-vars itemParams, multiLang, @@ -101,9 +201,22 @@ export const AutoFormEditor = ({ }: AutoFormEditorProps) => { const labelledBy = useEditorLabelledBy(label); + if (multiLang && format === "json") { + return ( + + ); + } + if (multiLang) { return ( - - + {format === "json" ? ( + + ) : ( + + )} {!!description && {description}} diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx index cd62d33cf9..861905ecaf 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang.tsx +++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx @@ -14,8 +14,9 @@ import { SelectValue, } from "@/components/ui/select"; import { + findLangValue, getLangValue, - pickLangCode, + pickLangCodeWhere, upsertLangValue, } from "@/lib/helpers/multi-lang"; @@ -32,33 +33,47 @@ export interface MultiLangFieldProps { field: FormFieldApi; } -export const useMultiLangField = ( - field: MultiLangFieldProps["field"], - { isFilled }: { isFilled?: (text: string) => boolean } = {}, +const useMultiLangSelection = ( + isFilledIn: (languageCode: string) => boolean, ) => { const languages = useLanguages(); const locale = useLocale(); const defaultLanguage = useMultiLangDefaultLanguage(); const lockedLanguage = useMultiLangLanguage(); - const { value } = field; const [selected, setSelected] = React.useState(() => - pickLangCode({ + pickLangCodeWhere({ defaultLanguage, - isFilled, + isFilledIn, languageCodes: languages.map(language => language.code), locale, - value, }), ); - const language = lockedLanguage ?? selected; + return { + canSelect: lockedLanguage === null && languages.length > 1, + language: lockedLanguage ?? selected, + languages, + setSelected, + }; +}; + +const hasText = (text: string): boolean => text.trim() !== ""; + +export const useMultiLangField = ( + field: MultiLangFieldProps["field"], + { isFilled = hasText }: { isFilled?: (text: string) => boolean } = {}, +) => { + const { value } = field; + const { canSelect, language, languages, setSelected } = useMultiLangSelection( + code => isFilled(getLangValue(value, code)), + ); const setValue = (newValue: string) => { field.onChange(upsertLangValue(value, language, newValue)); }; return { - canSelect: lockedLanguage === null && languages.length > 1, + canSelect, languages, selected: language, setSelected, @@ -67,6 +82,33 @@ export const useMultiLangField = ( }; }; +/** + * {@link useMultiLangField} for a value that is not text - a rich text + * document, say. `currentValue` is `undefined` for a language with no value. + */ +export const useMultiLangValueField = ( + field: FormFieldApi | undefined>, + { isFilled }: { isFilled: (value: TValue | undefined) => boolean }, +) => { + const { value } = field; + const { canSelect, language, languages, setSelected } = useMultiLangSelection( + code => isFilled(findLangValue(value, code)), + ); + + const setValue = (newValue: TValue) => { + field.onChange(upsertLangValue(value, language, newValue)); + }; + + return { + canSelect, + languages, + selected: language, + setSelected, + currentValue: findLangValue(value, language), + setValue, + }; +}; + export const MultiLangSelect = ({ languages, onSelect, diff --git a/packages/vitnode/src/components/rich-text/boundaries.test.ts b/packages/vitnode/src/components/rich-text/boundaries.test.ts new file mode 100644 index 0000000000..74c7569c27 --- /dev/null +++ b/packages/vitnode/src/components/rich-text/boundaries.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { externalGraph, reachedFiles } from "@/tests/import-graph"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const EDITOR_ONLY = + /^(?:@tiptap\/|prosemirror|y-|yjs$|@vitnode\/core\/components\/tiptap)/; + +const entries = { + "components/rich-text": join(here, "index.ts"), + "content/rich-text": join( + here, + "..", + "..", + "content", + "rich-text", + "index.ts", + ), +}; + +describe("the rich text renderer never ships the editor", () => { + it.each(Object.entries(entries))( + "%s reaches no Tiptap, ProseMirror or Yjs module", + (_name, entry) => { + const editorOnly = [...externalGraph(entry).keys()].filter(specifier => + EDITOR_ONLY.test(specifier), + ); + + expect(editorOnly).toStrictEqual([]); + }, + ); + + it.each(Object.entries(entries))( + "%s reaches no file of the editor itself", + (_name, entry) => { + expect( + reachedFiles(entry).filter(file => + file.startsWith("components/tiptap"), + ), + ).toStrictEqual([]); + }, + ); + + it("walks the renderer it guards", () => { + // Otherwise the checks above would pass on a graph that stopped at the + // entry file - which is exactly the graph that cannot break. + expect(reachedFiles(entries["components/rich-text"])).toEqual( + expect.arrayContaining([ + "components/rich-text/rich-text-content.tsx", + "content/rich-text/elements.ts", + "content/rich-text/html.ts", + ]), + ); + }); +}); diff --git a/packages/vitnode/src/components/rich-text/index.ts b/packages/vitnode/src/components/rich-text/index.ts new file mode 100644 index 0000000000..88084bba8e --- /dev/null +++ b/packages/vitnode/src/components/rich-text/index.ts @@ -0,0 +1,20 @@ +export { RichTextContent } from "./rich-text-content"; +export type { + RichTextContentProps, + RichTextMarkRenderer, + RichTextMarkRendererProps, + RichTextNodeRenderer, + RichTextNodeRendererProps, +} from "./rich-text-content"; + +export { + isRichTextEmpty, + richTextToHtml, + richTextToPlainText, + sanitizeRichTextHref, +} from "@/content/rich-text"; +export type { + RichTextDocument, + RichTextMark, + RichTextNode, +} from "@/content/rich-text"; diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx new file mode 100644 index 0000000000..767c0da99a --- /dev/null +++ b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx @@ -0,0 +1,379 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { + RichTextDocument, + RichTextMark, + RichTextNode, +} from "@/content/rich-text/document"; + +import { RichTextContent } from "./rich-text-content"; + +const text = (value: string, marks?: RichTextMark[]): RichTextNode => + marks ? { marks, text: value, type: "text" } : { text: value, type: "text" }; + +const p = (...content: RichTextNode[]): RichTextNode => ({ + content, + type: "paragraph", +}); + +const doc = (...content: RichTextNode[]): RichTextDocument => ({ + content, + type: "doc", +}); + +const renderDoc = (content: RichTextDocument) => + render().container; + +const textOf = (element: Element | null | undefined) => + element?.textContent ?? null; + +describe("RichTextContent", () => { + it("renders nothing without a document", () => { + const { container } = render(); + + expect(container.childElementCount).toBe(0); + }); + + it("renders headings at their level, with a safe alignment", () => { + renderDoc( + doc( + { attrs: { level: 3 }, content: [text("Setup")], type: "heading" }, + { + attrs: { level: 9, textAlign: "center" }, + content: [text("Fallback")], + type: "heading", + }, + { + attrs: { textAlign: "expression(alert(1))" }, + content: [text("Plain")], + type: "paragraph", + }, + ), + ); + + expect(textOf(screen.getByRole("heading", { level: 3 }))).toBe("Setup"); + expect(screen.getByRole("heading", { level: 2 }).style.textAlign).toBe( + "center", + ); + expect(screen.getByText("Plain").getAttribute("style")).toBeNull(); + }); + + it("renders every inline mark as its element", () => { + const container = renderDoc( + doc( + p( + text("bold", [{ type: "bold" }]), + text("italic", [{ type: "italic" }]), + text("strike", [{ type: "strike" }]), + text("code", [{ type: "code" }]), + text("underline", [{ type: "underline" }]), + text("both", [{ type: "bold" }, { type: "italic" }]), + ), + ), + ); + + expect(textOf(container.querySelector("p > strong"))).toBe("bold"); + expect(textOf(container.querySelector("p > em"))).toBe("italic"); + expect(textOf(container.querySelector("s"))).toBe("strike"); + expect(textOf(container.querySelector("code"))).toBe("code"); + expect(textOf(container.querySelector("u"))).toBe("underline"); + expect(textOf(container.querySelector("strong > em"))).toBe("both"); + }); + + it("renders text color and size, and drops unsafe values", () => { + const container = renderDoc( + doc( + p( + text("red", [ + { + attrs: { color: "#ff0000", fontSize: "18px" }, + type: "textStyle", + }, + ]), + text("evil", [ + { + attrs: { color: "red;background:url(x)", fontSize: "1e9px" }, + type: "textStyle", + }, + ]), + ), + ), + ); + + const styled = screen.getByText("red"); + expect(styled.style.color).toBe("rgb(255, 0, 0)"); + expect(styled.style.fontSize).toBe("18px"); + expect(screen.getByText("evil", { exact: false }).tagName).toBe("P"); + expect(container.querySelectorAll("span")).toHaveLength(1); + }); + + it("renders links, opening a new tab safely", () => { + renderDoc( + doc( + p( + text("docs", [{ attrs: { href: "/docs" }, type: "link" }]), + text("site", [ + { + attrs: { href: "https://vitnode.com", target: "_blank" }, + type: "link", + }, + ]), + ), + ), + ); + + const internal = screen.getByRole("link", { name: "docs" }); + expect(internal.getAttribute("href")).toBe("/docs"); + expect(internal.hasAttribute("target")).toBe(false); + + const external = screen.getByRole("link", { name: "site" }); + expect(external.getAttribute("target")).toBe("_blank"); + expect(external.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + }); + + it.each([ + "javascript:alert(1)", + " JAVASCRIPT:alert(1)", + "java\nscript:alert(1)", + "data:text/html;base64,PHNjcmlwdD4=", + "vbscript:msgbox(1)", + ])("renders a link to %j as plain text", href => { + renderDoc(doc(p(text("click me", [{ attrs: { href }, type: "link" }])))); + + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.getByText("click me").tagName).toBe("P"); + }); + + it("renders lists, keeping an ordered list's start", () => { + renderDoc( + doc( + { + content: [{ content: [p(text("Bullet"))], type: "listItem" }], + type: "bulletList", + }, + { + attrs: { start: 3 }, + content: [ + { content: [p(text("Third"))], type: "listItem" }, + { content: [p(text("Fourth"))], type: "listItem" }, + ], + type: "orderedList", + }, + ), + ); + + const [bullets, numbered] = screen.getAllByRole("list"); + expect(bullets.tagName).toBe("UL"); + expect(textOf(within(bullets).getByRole("listitem"))).toBe("Bullet"); + expect(numbered.tagName).toBe("OL"); + expect(numbered.getAttribute("start")).toBe("3"); + expect(within(numbered).getAllByRole("listitem")).toHaveLength(2); + }); + + it("renders blocks: quote, code, rule and a hard break", () => { + const container = renderDoc( + doc( + { content: [p(text("Quoted"))], type: "blockquote" }, + { + attrs: { language: "ts" }, + content: [text("const a = 1;")], + type: "codeBlock", + }, + { + attrs: { language: '">", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "", + ])("refuses %j", href => { + expect(sanitizeRichTextHref(href)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index ffac3c263e..fcea903958 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -44,6 +44,10 @@ import { isContentReferenceCollection, splitContentFieldPath, } from "./paths"; +import { + createRichTextDocumentSchema, + RICH_TEXT_ABSOLUTE_MAX_BYTES, +} from "./rich-text/document"; /** What a content type without `publicApi` carries: nothing exposed at all. */ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { @@ -230,6 +234,12 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { }), ); } + // What is stored is what crosses back: the document is read as it was + // written, and the field's own size limit is a rule for *writing* it. + case "richText": + return createRichTextDocumentSchema({ + maxBytes: RICH_TEXT_ABSOLUTE_MAX_BYTES, + }); case "slug": // Never empty: the service normalises before writing, and a value that // folds to nothing is rejected rather than stored. @@ -250,6 +260,14 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { /** The value as it arrives from a client. `dateTime` is an ISO 8601 string. */ const baseInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { if (fieldValue.kind === "dateTime") return z.iso.datetime(); + // `required` is "not empty" for a document: `{ type: "doc" }` is present, + // and says nothing. + if (fieldValue.kind === "richText") { + return createRichTextDocumentSchema({ + maxBytes: fieldValue.maxBytes, + required: fieldValue.required, + }); + } return baseSelectSchema(fieldValue); }; @@ -274,6 +292,7 @@ const applyPresence = ( fieldValue.kind !== "group" && fieldValue.kind !== "relation" && fieldValue.kind !== "repeatable" && + fieldValue.kind !== "richText" && fieldValue.kind !== "slug" && fieldValue.kind !== "user" && fieldValue.defaultValue !== undefined diff --git a/packages/vitnode/src/content/search.test.ts b/packages/vitnode/src/content/search.test.ts index d8fb67a022..c4a29aade7 100644 --- a/packages/vitnode/src/content/search.test.ts +++ b/packages/vitnode/src/content/search.test.ts @@ -172,7 +172,7 @@ describe("search configuration", () => { expect(() => define({ search: { ...validSearch, contentFields: ["featured"] } }), ).toThrow( - /search.contentFields names "featured" of kind "boolean"\. Expected one of: slug, text, textarea\./, + /search.contentFields names "featured" of kind "boolean"\. Expected one of: richText, slug, text, textarea\./, ); }); diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 2e0af23465..38da0cc93c 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -12,6 +12,7 @@ import { } from "drizzle-orm/pg-core"; import type { AnyBlockInstance } from "../../blocks/types"; +import type { RichTextDocument } from "../rich-text/document"; import type { ContentFieldDescriptor } from "../types"; import { @@ -207,6 +208,11 @@ export const buildContentColumn = ({ return nullable ? column : column.notNull(); } + case "richText": { + const column = jsonb().$type(); + + return nullable ? column : column.notNull(); + } case "slug": // Always NOT NULL and never defaulted: a row nobody can address by URL // is not worth allowing, and there is no sensible default URL. diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index 02b06c7d6c..8d39cb8759 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -1,7 +1,7 @@ import type { SQL } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; -import { and, eq, ilike, isNull, or } from "drizzle-orm"; +import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; import type { ContentFieldDescriptor, @@ -38,7 +38,18 @@ export const buildSearchCondition = ( const pattern = containsLikePattern(trimmed); - return or(...columns.map(column => ilike(column, pattern))); + return or( + ...columns.map(column => + // A rich text column is a ProseMirror document. Matching its JSON would + // find "paragraph" in every row, so only the text nodes are searched. + column.getSQLType() === "jsonb" + ? ilike( + sql`jsonb_path_query_array(${column}, 'strict $.**.text')::text`, + pattern, + ) + : ilike(column, pattern), + ), + ); }; const filterValue = ( diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 7dff0b57aa..cd8c43e0d2 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -13,6 +13,7 @@ import { isContentReferenceCollection, readContentLeaf, } from "../paths"; +import { isRichTextDocument } from "../rich-text/document"; const toIso = (value: unknown): string => { if (value instanceof Date) return value.toISOString(); @@ -71,6 +72,13 @@ const toFieldSnapshot = ( : []; } + // A document is kept whole, so a restore puts back exactly what was there. + if (fieldValue.kind === "richText") { + const value = values[name]; + + return isRichTextDocument(value) ? value : null; + } + if (fieldValue.kind === "repeatable") { const value = values[name]; if (!Array.isArray(value)) return []; @@ -173,7 +181,10 @@ const projectSnapshotFields = ( if (fieldValue.kind === "group") { projected[name] = - value === null || typeof value !== "object" || Array.isArray(value) + value === null || + typeof value !== "object" || + Array.isArray(value) || + isRichTextDocument(value) ? null : pickLeaves(value, leaves); continue; diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 2ee245a5bc..b20a178ea9 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -8,11 +8,17 @@ import { } from "../cache"; import { partitionContentFields } from "../localization"; import { readContentPath, splitContentFieldPath } from "../paths"; +import { isRichTextDocument } from "../rich-text/document"; +import { richTextToPlainText } from "../rich-text/plain-text"; import { contentSearchUrl } from "../search"; /** Collapses whitespace so a multi-line value cannot break a result heading. */ -const normalize = (value: unknown): string => - typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; +const normalize = (value: unknown): string => { + // A rich text field is indexed as its words, never as its JSON. + const text = isRichTextDocument(value) ? richTextToPlainText(value) : value; + + return typeof text === "string" ? text.replace(/\s+/g, " ").trim() : ""; +}; const readSearchValue = ( values: Record, diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index c65d111482..8196ed6d30 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -1,9 +1,11 @@ +import type { $Type } from "drizzle-orm"; import type { AnyPgColumnBuilder, PgBooleanBuilder, PgBuildColumns, PgDoublePrecisionBuilder, PgIntegerBuilder, + PgJsonbBuilder, PgSerialBuilder, PgTableWithColumns, PgTextBuilder, @@ -13,6 +15,7 @@ import type { SetNotNull, } from "drizzle-orm/pg-core"; +import type { RichTextDocument } from "../rich-text/document"; import type { ContentEditorialField, ContentFieldsOf, @@ -46,7 +49,9 @@ type BaseBuilderFor = TField extends { kind: "boolean" } ? PgIntegerBuilder : TField extends { kind: "textarea" } ? PgTextBuilder - : PgVarcharBuilder; + : TField extends { kind: "richText" } + ? $Type + : PgVarcharBuilder; type ApplyDefault = HasColumnDefault extends true ? SetHasDefault : TBuilder; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index a9cfb7b5a1..cccd8ed49d 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -18,6 +18,7 @@ import type { CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; import type { ContentFileDescriptor } from "./files"; +import type { RichTextDocument } from "./rich-text/document"; import type { ContentSchemas } from "./schemas"; export type ContentSystemField = (typeof CONTENT_SYSTEM_FIELDS)[number]; @@ -119,6 +120,17 @@ export interface ContentTextareaField< minLength?: number; } +export interface ContentRichTextField< + TRequired extends boolean = boolean, + TNullable extends boolean = boolean, + TLocalized extends boolean = boolean, +> extends ContentFieldShared { + kind: "richText"; + localized: TLocalized; + /** Largest serialized document, in bytes. Defaults to 1 MB. */ + maxBytes?: number; +} + export interface ContentNumberField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -276,6 +288,7 @@ export type ContentFieldDescriptor = | ContentNumberField | ContentRelationField | ContentRepeatableField + | ContentRichTextField | ContentSlugField | ContentTextareaField | ContentTextField @@ -328,7 +341,9 @@ type ScalarFieldValue = TField extends { kind: "boolean" } ? TValue : TField extends { kind: "file" | "number" | "relation" | "user" } ? number - : string; + : TField extends { kind: "richText" } + ? RichTextDocument + : string; /** The scalar half of {@link ContentFieldInput}. `dateTime` crosses as ISO. */ type ScalarFieldInput = TField extends { kind: "boolean" } @@ -339,7 +354,9 @@ type ScalarFieldInput = TField extends { kind: "boolean" } ? TValue : TField extends { kind: "file" | "number" | "relation" | "user" } ? number - : string; + : TField extends { kind: "richText" } + ? RichTextDocument + : string; /** Every leaf of a group, as it comes back. Nested, never flattened. */ type ContentGroupValue = Prettify<{ diff --git a/packages/vitnode/src/lib/helpers/multi-lang.ts b/packages/vitnode/src/lib/helpers/multi-lang.ts index 6186782a39..31a2f9a097 100644 --- a/packages/vitnode/src/lib/helpers/multi-lang.ts +++ b/packages/vitnode/src/lib/helpers/multi-lang.ts @@ -4,12 +4,13 @@ import type { InputParams } from "./auto-form"; import { getNestedParam } from "./auto-form"; -export interface MultiLangValueItem { +export interface MultiLangValueItem { languageCode: string; - value: string; + value: TValue; } -export type MultiLangValue = MultiLangValueItem[]; +/** One value per language. Text by default; a rich text field holds documents. */ +export type MultiLangValue = MultiLangValueItem[]; export const multiLangValueSchema = ({ maxLength, @@ -49,20 +50,31 @@ export const getLangValue = ( ); }; +/** The value stored for one language, whatever its type, or `undefined`. */ +export const findLangValue = ( + value: MultiLangValue | undefined, + languageCode: string, +): TValue | undefined => + (Array.isArray(value) ? value : []).find( + item => item.languageCode === languageCode, + )?.value; + const hasText = (text: string): boolean => text.trim() !== ""; -export const pickLangCode = ({ +/** + * The language a multi-language field opens on: the reader's own when it is + * filled, then the default language, then the first filled one. + */ +export const pickLangCodeWhere = ({ defaultLanguage, - isFilled = hasText, + isFilledIn, languageCodes, locale, - value, }: { defaultLanguage?: null | string; - isFilled?: (text: string) => boolean; + isFilledIn: (languageCode: string) => boolean; languageCodes: readonly string[]; locale: string; - value: MultiLangValue | string | undefined; }): string => { const current = languageCodes.includes(locale) ? locale @@ -74,10 +86,30 @@ export const pickLangCode = ({ (code): code is string => typeof code === "string" && languageCodes.includes(code), ) - .find(code => isFilled(getLangValue(value, code))) ?? current + .find(isFilledIn) ?? current ); }; +export const pickLangCode = ({ + defaultLanguage, + isFilled = hasText, + languageCodes, + locale, + value, +}: { + defaultLanguage?: null | string; + isFilled?: (text: string) => boolean; + languageCodes: readonly string[]; + locale: string; + value: MultiLangValue | string | undefined; +}): string => + pickLangCodeWhere({ + defaultLanguage, + isFilledIn: code => isFilled(getLangValue(value, code)), + languageCodes, + locale, + }); + export const resolveLangValue = ( value: MultiLangValue | string | undefined, { @@ -98,11 +130,11 @@ export const resolveLangValue = ( }), ); -export const upsertLangValue = ( - value: MultiLangValue | undefined, +export const upsertLangValue = ( + value: MultiLangValue | undefined, languageCode: string, - newValue: string, -): MultiLangValue => { + newValue: TValue, +): MultiLangValue => { const current = Array.isArray(value) ? value : []; if (current.some(item => item.languageCode === languageCode)) { diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx index df2b3023a5..d75591b02b 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx @@ -15,6 +15,8 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { isRichTextDocument } from "@/content/rich-text/document"; +import { richTextToPlainText } from "@/content/rich-text/plain-text"; export interface ContentConflictState { currentVersion: number; @@ -24,6 +26,7 @@ export interface ContentConflictState { const asText = (value: unknown): string => { if (value === null || value === undefined || value === "") return "—"; if (value instanceof Date) return value.toISOString(); + if (isRichTextDocument(value)) return richTextToPlainText(value) || "—"; // A row arrives as JSON, so a value is a primitive or it is something the // comparison has no opinion about - stringifying an object would compare diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx index abb560051a..ef0e7e8b48 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -12,6 +12,11 @@ import type { import { DateFormat } from "@/components/date-format"; import { Badge } from "@/components/ui/badge"; import { contentRevisionDiff } from "@/content/revisions"; +import { + isRichTextDocument, + isRichTextEmpty, +} from "@/content/rich-text/document"; +import { richTextToPlainText } from "@/content/rich-text/plain-text"; const TEXTAREA_PREVIEW_LINES = 8; @@ -25,6 +30,7 @@ const TOKEN_KINDS = new Set([ const isBlank = (value: ContentSnapshotValue | undefined): boolean => { if (value === null || value === undefined || value === "") return true; + if (isRichTextDocument(value)) return isRichTextEmpty(value); if (Array.isArray(value)) return value.length === 0; if (typeof value === "object") { @@ -42,6 +48,21 @@ const Empty = ({ label }: { label: string }) => ( ); +const TextPreview = ({ text }: { text: string }) => { + const lines = text.split("\n"); + + return lines.length > TEXTAREA_PREVIEW_LINES ? ( +
+ + {lines.slice(0, TEXTAREA_PREVIEW_LINES).join("\n")} + + {text} +
+ ) : ( + {text} + ); +}; + const Value = ({ emptyLabel, kind, @@ -57,6 +78,11 @@ const Value = ({ }) => { if (isBlank(value)) return ; + // A document is compared as the words a reader sees, never as its JSON. + if (isRichTextDocument(value)) { + return ; + } + if (Array.isArray(value)) { if (typeof value[0] === "number") { return ( @@ -107,21 +133,8 @@ const Value = ({ case "user": return {labels[String(value)] ?? `#${String(value)}`}; - case "textarea": { - const text = String(value); - const lines = text.split("\n"); - - return lines.length > TEXTAREA_PREVIEW_LINES ? ( -
- - {lines.slice(0, TEXTAREA_PREVIEW_LINES).join("\n")} - - {text} -
- ) : ( - {text} - ); - } + case "textarea": + return ; default: return {String(value)}; diff --git a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx index 4932922a55..987dd5e080 100644 --- a/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/skeleton.tsx @@ -49,6 +49,7 @@ const controlOfKind: Record = { number: "input", relation: "input", repeatable: "list", + richText: "editor", slug: "input", text: "input", textarea: "textarea", diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index dc70321afc..51e2f24671 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { useLocale, useTranslations } from "use-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; @@ -20,6 +21,7 @@ import { AutoFormSelect } from "@/components/form/fields/select"; import { AutoFormSwitch } from "@/components/form/fields/switch"; import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { ContentFormFieldSkeleton } from "../form/skeleton"; import { ContentGroupField } from "./group-field"; import { ContentOptionSwatch } from "./option-swatch"; import { contentOptionsQueryKey } from "./options-query"; @@ -71,6 +73,14 @@ const fileList = ( return Array.isArray(value) ? value : []; }; +// The editor is the heaviest thing a form can render, so only a form with a +// rich text field ever downloads it. +const AutoFormEditor = React.lazy(async () => + import("@/components/form/fields/editor").then(module => ({ + default: module.AutoFormEditor, + })), +); + type ContentKindFieldProps = ItemAutoFormComponentProps & { spec: ContentFormFieldSpec; }; @@ -255,6 +265,20 @@ export const ContentField = ({ /> ); + case "richText": + return ( + } + > + + + ); + case "textarea": return ( { files?: Record; @@ -236,6 +238,13 @@ const ContentCellValue = ({ ); } + case "richText": + return ( + + {isRichTextDocument(value) ? richTextToPlainText(value) : ""} + + ); + case "system": return spec.name === "id" ? ( {asText(value)} diff --git a/plugins/blog/src/api/ai/actions.ts b/plugins/blog/src/api/ai/actions.ts index 78fa46a907..62c1bae574 100644 --- a/plugins/blog/src/api/ai/actions.ts +++ b/plugins/blog/src/api/ai/actions.ts @@ -2,7 +2,11 @@ import { defineAiAction } from "@vitnode/core/api/lib/ai/action"; import { assertSameHtmlStructure } from "@vitnode/core/api/lib/ai/html-structure"; import { aiActionRef } from "@vitnode/core/api/lib/ai/registry"; import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; +import { + createRichTextDocumentSchema, + richTextToHtml, + richTextToPlainText, +} from "@vitnode/core/content/rich-text"; import { z } from "zod"; import { @@ -15,15 +19,30 @@ import { CONFIG_PLUGIN } from "@/const"; const zodLocale = z.string().min(2).max(16); -export const zodTranslateAiSchema = z.object({ - format: z.enum(["html", "text"]), - from: zodLocale, - text: z.string().trim().min(1).max(100_000), - to: zodLocale, -}); +export const zodTranslateAiSchema = z.discriminatedUnion("format", [ + z.object({ + format: z.literal("text"), + from: zodLocale, + text: z.string().trim().min(1).max(100_000), + to: zodLocale, + }), + z.object({ + document: createRichTextDocumentSchema({ required: true }), + format: z.literal("richText"), + from: zodLocale, + to: zodLocale, + }), +]); + +type TranslateAiInput = z.infer; + +const translateSource = (input: TranslateAiInput) => + input.format === "richText" + ? { format: "html" as const, text: richTextToHtml(input.document) } + : { format: "text" as const, text: input.text }; export const zodExcerptAiSchema = z.object({ - content: z.string().trim().min(1).max(200_000), + content: createRichTextDocumentSchema({ required: true }), locale: zodLocale, title: z.string().trim().min(1).max(255), }); @@ -43,7 +62,10 @@ const canEditPosts = async ({ const translateFieldAiAction = defineAiAction({ authorize: canEditPosts, buildPrompt: (input, { instructions }) => - buildTranslatePrompt(input, instructions), + buildTranslatePrompt( + { ...translateSource(input), from: input.from, to: input.to }, + instructions, + ), defaults: { maxInputCharacters: 100_000, maxOutputTokens: 32_000, @@ -54,13 +76,13 @@ const translateFieldAiAction = defineAiAction({ id: "field.translate", title: "ai_actions.@vitnode/blog.field_translate.title", inputSchema: zodTranslateAiSchema, - measureInput: input => input.text.length, + measureInput: input => translateSource(input).text.length, output: "text", outputSchema: z.string().min(1), parseText: (text, input) => { if (input.format === "text") return unquote(text); const translated = text.trim(); - assertSameHtmlStructure(input.text, translated); + assertSameHtmlStructure(translateSource(input).text, translated); return translated; }, @@ -72,7 +94,10 @@ const translateFieldAiAction = defineAiAction({ const excerptAiAction = defineAiAction({ authorize: canEditPosts, buildPrompt: (input, { instructions }) => - buildExcerptPrompt(input, instructions), + buildExcerptPrompt( + { ...input, content: richTextToPlainText(input.content) }, + instructions, + ), defaults: { maxInputCharacters: 12_500, maxOutputTokens: 300, @@ -84,7 +109,8 @@ const excerptAiAction = defineAiAction({ title: "ai_actions.@vitnode/blog.excerpt_generate.title", inputSchema: zodExcerptAiSchema, measureInput: input => - input.title.length + excerptSource(input.content).length, + input.title.length + + excerptSource(richTextToPlainText(input.content)).length, output: "text", outputSchema: z.string().min(1).max(300), parseText: text => unquote(text), @@ -116,7 +142,7 @@ const zodArticleReview = z.object({ const articleReviewAiAction = defineAiAction({ authorize: canEditPosts, buildPrompt: (input, { instructions }) => ({ - prompt: `Title: ${input.title}\n\nExcerpt: ${input.excerpt?.trim() ? input.excerpt : "(none)"}\n\nArticle:\n${stripHtml(input.content).slice(0, 30_000)}`, + prompt: `Title: ${input.title}\n\nExcerpt: ${input.excerpt?.trim() ? input.excerpt : "(none)"}\n\nArticle:\n${richTextToPlainText(input.content).slice(0, 30_000)}`, system: [ "You review a blog article draft before it is published and suggest improvements an editor can act on.", "Focus on clarity, completeness (missing context, undefined terms, an unclear conclusion), structure and tone.", @@ -136,7 +162,7 @@ const articleReviewAiAction = defineAiAction({ id: "article.review", title: "ai_actions.@vitnode/blog.article_review.title", inputSchema: z.object({ - content: z.string().trim().min(1).max(200_000), + content: createRichTextDocumentSchema({ required: true }), excerpt: z.string().max(1_000).optional(), locale: zodLocale, title: z.string().trim().min(1).max(255), @@ -144,7 +170,7 @@ const articleReviewAiAction = defineAiAction({ measureInput: input => input.title.length + (input.excerpt?.length ?? 0) + - Math.min(stripHtml(input.content).length, 30_000), + Math.min(richTextToPlainText(input.content).length, 30_000), output: "object", outputSchema: zodArticleReview, permission: { defaultGranted: true, key: "review" }, diff --git a/plugins/blog/src/api/lib/ai-writing.ts b/plugins/blog/src/api/lib/ai-writing.ts index 04715e281d..3189fdc3b3 100644 --- a/plugins/blog/src/api/lib/ai-writing.ts +++ b/plugins/blog/src/api/lib/ai-writing.ts @@ -1,7 +1,6 @@ import type { AiPrompt } from "@vitnode/core/api/lib/ai/action"; import { languageName } from "@vitnode/core/api/lib/ai/language-name"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; export type AiTextFormat = "html" | "text"; @@ -41,8 +40,8 @@ export const buildTranslatePrompt = ( prompt: text, }); -export const excerptSource = (content: string) => - stripHtml(content).slice(0, EXCERPT_SOURCE_CHARACTERS); +export const excerptSource = (plainText: string) => + plainText.slice(0, EXCERPT_SOURCE_CHARACTERS); export const buildExcerptPrompt = ( { diff --git a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts index 4f579f4e69..163c29b31c 100644 --- a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts +++ b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts @@ -157,20 +157,49 @@ describe("blog AI admin routes", () => { expect(promptOf(model.doGenerateCalls[0])).toContain("Hello, world"); }); - it("keeps HTML intact when translating content", async () => { + it("translates a rich text document as HTML, keeping its markup", async () => { const { app, model } = await createApp({ - model: modelAnswering("

Cześć

\n"), + model: modelAnswering("

Cześć

\n"), }); const response = await post(app, "/translate", { - format: "html", + document: { + content: [ + { + content: [ + { marks: [{ type: "bold" }], text: "Hello", type: "text" }, + ], + type: "paragraph", + }, + ], + type: "doc", + }, + format: "richText", from: "en", - text: "

Hello

", to: "pl", }); - expect(await response.json()).toEqual({ text: "

Cześć

" }); + expect(await response.json()).toEqual({ + text: "

Cześć

", + }); expect(promptOf(model.doGenerateCalls[0])).toContain("Keep every tag"); + expect(promptOf(model.doGenerateCalls[0])).toContain( + "

Hello

", + ); + }); + + it("refuses an empty document before asking the model", async () => { + const { app, model } = await createApp(); + + const response = await post(app, "/translate", { + document: { content: [{ type: "paragraph" }], type: "doc" }, + format: "richText", + from: "en", + to: "pl", + }); + + expect(response.status).toBe(400); + expect(model.doGenerateCalls).toHaveLength(0); }); it("writes an excerpt from the article text", async () => { @@ -181,7 +210,20 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/excerpt", { - content: "

Why

We rebuilt the AdminCP.

", + content: { + content: [ + { + attrs: { level: 2 }, + content: [{ text: "Why", type: "text" }], + type: "heading", + }, + { + content: [{ text: "We rebuilt the AdminCP.", type: "text" }], + type: "paragraph", + }, + ], + type: "doc", + }, locale: "en", title: "VitNode 2.0", }); @@ -193,7 +235,7 @@ describe("blog AI admin routes", () => { expect(promptOf(model.doGenerateCalls[0])).toContain( "We rebuilt the AdminCP.", ); - expect(promptOf(model.doGenerateCalls[0])).not.toContain("

"); + expect(promptOf(model.doGenerateCalls[0])).not.toContain("paragraph"); }); it("refuses staff who cannot edit articles", async () => { @@ -214,7 +256,12 @@ describe("blog AI admin routes", () => { const { app, model } = await createApp({ configured: false }); const response = await post(app, "/excerpt", { - content: "

Body

", + content: { + content: [ + { content: [{ text: "Body", type: "text" }], type: "paragraph" }, + ], + type: "doc", + }, locale: "en", title: "Title", }); diff --git a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts index 2c0a458aaa..25966908c7 100644 --- a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts +++ b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts @@ -26,7 +26,8 @@ export const translateAiAdminRoute = buildRoute({ content: { "application/json": { schema: z.object({ text: z.string() }) }, }, - description: "The translated text", + description: + "The translated text, or HTML when a rich text document was sent", }, 400: { description: "No AI model is configured or the input is invalid" }, 403: { description: "No access to posts or to this AI feature" }, diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index 4b3d90a6cc..fa9365e3fa 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -50,7 +50,9 @@ export const blogPostContentType = defineContentType({ maxLength: 255, source: "title", }), - content: field.textarea({ localized: true, required: true }), + // ProseMirror JSON, rendered on the server by `RichTextContent` - the public + // page never downloads the editor. + content: field.richText({ localized: true, required: true }), excerpt: field.textarea({ ai: { action: "@vitnode/blog:excerpt.generate", diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index f99f72991d..db1a529e6f 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -1,6 +1,9 @@ import { DateFormat } from "@vitnode/core/components/date-format"; -import { EditorContent } from "@vitnode/core/components/ui/editor-content"; -import { zodContentFileDescriptor } from "@vitnode/core/content"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { + zodContentFileDescriptor, + zodRichTextDocument, +} from "@vitnode/core/content"; import { resolveImageAlt } from "@vitnode/core/lib/files/resolve-alt"; import { definePluginRoute, @@ -18,7 +21,7 @@ import { z } from "zod"; import { CONFIG_PLUGIN } from "@/const"; const zodBlogPost = z.object({ - content: z.string(), + content: zodRichTextDocument, coverImage: zodContentFileDescriptor.nullable(), coverImageAlt: z.string().nullable(), publishedAt: z.string().nullable(), @@ -122,9 +125,10 @@ const BlogPostPage = ({ /> ) : null} -
- -
+ ); }; diff --git a/plugins/blog/src/views/admin/article/editor-field.tsx b/plugins/blog/src/views/admin/article/editor-field.tsx index 89884e44da..ca26d4138b 100644 --- a/plugins/blog/src/views/admin/article/editor-field.tsx +++ b/plugins/blog/src/views/admin/article/editor-field.tsx @@ -19,6 +19,7 @@ export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => { diff --git a/plugins/blog/src/views/admin/article/editor/ai.ts b/plugins/blog/src/views/admin/article/editor/ai.ts index 9baa37fe86..cedd766cd4 100644 --- a/plugins/blog/src/views/admin/article/editor/ai.ts +++ b/plugins/blog/src/views/admin/article/editor/ai.ts @@ -1,3 +1,6 @@ +import type { EditorEmojiSection } from "@vitnode/core/components/editor-provider"; +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { AiRequestError, requestAiAssist, @@ -6,6 +9,8 @@ import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { CONFIG_PLUGIN } from "@/const"; +import { articleContentFromHtml } from "./content-html"; + export class ArticleAiError extends Error { constructor(status: number) { super(`The AI request failed with status ${status}.`); @@ -17,7 +22,6 @@ export class ArticleAiError extends Error { } export const translateArticleText = async (body: { - format: "html" | "text"; from: string; text: string; to: string; @@ -27,7 +31,7 @@ export const translateArticleText = async (body: { method: "post", module: "admin/ai", path: "/translate", - args: { body }, + args: { body: { ...body, format: "text" } }, }); if (response.status !== 200) throw new ArticleAiError(response.status); @@ -36,8 +40,33 @@ export const translateArticleText = async (body: { return text; }; +export const translateArticleContent = async ({ + customEmojis, + document, + from, + to, +}: { + customEmojis?: EditorEmojiSection[]; + document: RichTextDocument; + from: string; + to: string; +}): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/ai", + path: "/translate", + args: { body: { document, format: "richText", from, to } }, + }); + if (response.status !== 200) throw new ArticleAiError(response.status); + + const { text } = await response.json(); + + return articleContentFromHtml(text, { customEmojis }); +}; + export const writeArticleExcerpt = async (input: { - content: string; + content: RichTextDocument; locale: string; title: string; }): Promise => { diff --git a/plugins/blog/src/views/admin/article/editor/article-editor.tsx b/plugins/blog/src/views/admin/article/editor/article-editor.tsx index bd454eb5c9..133a60bc4e 100644 --- a/plugins/blog/src/views/admin/article/editor/article-editor.tsx +++ b/plugins/blog/src/views/admin/article/editor/article-editor.tsx @@ -1,8 +1,11 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { Link } from "@tanstack/react-router"; +import { useEditorConfig } from "@vitnode/core/components/editor-provider"; import { MultiLangLanguageContext } from "@vitnode/core/components/form/fields/multi-lang-language"; import { useLanguages } from "@vitnode/core/components/languages-provider"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; import { Button } from "@vitnode/core/components/ui/button"; -import { EditorContent } from "@vitnode/core/components/ui/editor-content"; import { ContentFormActions, ContentFormField, @@ -22,7 +25,11 @@ import { ArrowLeftIcon, ListChecksIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; -import { translateArticleText, writeArticleExcerpt } from "./ai"; +import { + translateArticleContent, + translateArticleText, + writeArticleExcerpt, +} from "./ai"; import { ArticleAiReview } from "./ai-review"; import { type ArticleFieldActions, @@ -32,7 +39,10 @@ import { type CheckAction, Previews, ReadinessList } from "./publish-panel"; import { type ArticleCheck, articleChecks, + articleContent, + type ArticleTextField, type ArticleValues, + fieldText, REQUIRED_TRANSLATED_FIELDS, type TranslatedField, translatedFieldStatus, @@ -71,6 +81,7 @@ export const ArticleEditor = ({ valuesRef.current = values; }); const setFormValue = useSetContentFormValue(); + const { emojis } = useEditorConfig(); const ai = useArticleAi(); const [target, setTarget] = React.useState(null); const [contentRevision, setContentRevision] = React.useState(0); @@ -96,7 +107,7 @@ export const ArticleEditor = ({ const ready = checks.filter(check => check.ok).length; const setLangValue = ( - field: TranslatedField, + field: ArticleTextField, locale: string, text: string, ) => { @@ -104,7 +115,20 @@ export const ArticleEditor = ({ field, upsertLangValue(valuesRef.current[field], locale, text), ); - if (field === "content") setContentRevision(revision => revision + 1); + }; + + // The editor is uncontrolled once it mounts, so a document written from + // outside it remounts it on the new value. + const setContent = (locale: string, document: RichTextDocument) => { + setFormValue( + "content", + upsertLangValue( + valuesRef.current.content, + locale, + document, + ), + ); + setContentRevision(revision => revision + 1); }; const statusOf = (field: TranslatedField, locale: string) => @@ -123,11 +147,27 @@ export const ArticleEditor = ({ await ai.run( `${field}:${to}`, async () => { + if (field === "content") { + const document = articleContent(valuesRef.current, source); + if (!document) return; + + setContent( + to, + await translateArticleContent({ + customEmojis: emojis, + document, + from: source, + to, + }), + ); + + return; + } + setLangValue( field, to, await translateArticleText({ - format: field === "content" ? "html" : "text", from: source, text: getLangValue(valuesRef.current[field], source), to, @@ -175,9 +215,9 @@ export const ArticleEditor = ({ "excerpt", locale, await writeArticleExcerpt({ - content: - getLangValue(current.content, locale) || - getLangValue(current.content, source), + content: (fieldText(current, "content", locale) + ? articleContent(current, locale) + : articleContent(current, source)) ?? { type: "doc" }, locale, title: getLangValue(current.title, locale) || @@ -497,8 +537,8 @@ export const ArticleEditor = ({ action={fieldAction("content", target)} source={
-
} diff --git a/plugins/blog/src/views/admin/article/editor/content-html.ts b/plugins/blog/src/views/admin/article/editor/content-html.ts new file mode 100644 index 0000000000..dd58525533 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor/content-html.ts @@ -0,0 +1,14 @@ +import type { EditorEmojiSection } from "@vitnode/core/components/editor-provider"; +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + +import { richTextDocumentFromHtml } from "@vitnode/core/components/tiptap/rich-text-json"; + +/** + * The one place translated HTML becomes article content. Today it replaces the + * document; once the article is co-edited, this is where the change goes in + * through editor commands instead, so it reaches every open editor. + */ +export const articleContentFromHtml = ( + html: string, + { customEmojis }: { customEmojis?: EditorEmojiSection[] } = {}, +): RichTextDocument => richTextDocumentFromHtml(html, { customEmojis }); diff --git a/plugins/blog/src/views/admin/article/editor/publish-panel.tsx b/plugins/blog/src/views/admin/article/editor/publish-panel.tsx index 01a9768576..feaa1d5965 100644 --- a/plugins/blog/src/views/admin/article/editor/publish-panel.tsx +++ b/plugins/blog/src/views/admin/article/editor/publish-panel.tsx @@ -2,7 +2,6 @@ import type { ContentFileFieldValue } from "@vitnode/core/content/files"; import { Button } from "@vitnode/core/components/ui/button"; import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; import { cn } from "cn"; import { CircleAlertIcon, CircleCheckIcon, SparklesIcon } from "lucide-react"; import React from "react"; @@ -11,6 +10,7 @@ import { useTranslations } from "use-intl"; import { type ArticleCheck, type ArticleValues, + fieldText, RECOMMENDED_LENGTH, } from "./readiness"; @@ -199,11 +199,11 @@ export const Previews = ({ const excerpt = pick("excerpt"); const description = excerpt.text.trim() || - stripHtml( - getLangValue(values.content, locale) || - getLangValue(values.content, source), + ( + fieldText(values, "content", locale) || + fieldText(values, "content", source) ) - .trim() + .replace(/\s+/g, " ") .slice(0, RECOMMENDED_LENGTH.excerpt); const searchTitle = title.text.length > RECOMMENDED_LENGTH.title diff --git a/plugins/blog/src/views/admin/article/editor/readiness.test.ts b/plugins/blog/src/views/admin/article/editor/readiness.test.ts index 069cbefd97..9220645f37 100644 --- a/plugins/blog/src/views/admin/article/editor/readiness.test.ts +++ b/plugins/blog/src/views/admin/article/editor/readiness.test.ts @@ -1,19 +1,34 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { describe, expect, it } from "vitest"; import { articleChecks, type ArticleValues, + fieldText, translatedFieldStatus, } from "./readiness"; -const lang = (entries: Record) => +const lang = (entries: Record) => Object.entries(entries).map(([languageCode, value]) => ({ languageCode, value, })); +const doc = (...paragraphs: string[]): RichTextDocument => ({ + content: paragraphs.map(text => + text === "" + ? { type: "paragraph" } + : { content: [{ text, type: "text" }], type: "paragraph" }, + ), + type: "doc", +}); + const values: ArticleValues = { - content: lang({ en: "

Hello world

", pl: "

" }), + content: lang({ + en: doc("Hello world"), + pl: doc(""), + }), coverImage: 12, coverImageAlt: lang({ en: "A dashboard" }), excerpt: lang({ en: "Short summary" }), @@ -24,8 +39,33 @@ const values: ArticleValues = { }), }; +describe("fieldText", () => { + it("reads the article body as the words of its document", () => { + expect( + fieldText( + { + content: lang({ en: doc("One", "", "Two") }), + }, + "content", + "en", + ), + ).toBe("One\nTwo"); + }); + + it("reads a language with no document as empty", () => { + expect( + fieldText( + { content: lang({ pl: null }) }, + "content", + "pl", + ), + ).toBe(""); + expect(fieldText(values, "content", "de")).toBe(""); + }); +}); + describe("translatedFieldStatus", () => { - it("treats an editor holding only empty markup as missing", () => { + it("treats an editor holding only an empty paragraph as missing", () => { expect( translatedFieldStatus(values, "content", { source: "en", target: "pl" }), ).toBe("missing"); diff --git a/plugins/blog/src/views/admin/article/editor/readiness.ts b/plugins/blog/src/views/admin/article/editor/readiness.ts index ebdee1b1cf..8240e97ab7 100644 --- a/plugins/blog/src/views/admin/article/editor/readiness.ts +++ b/plugins/blog/src/views/admin/article/editor/readiness.ts @@ -1,7 +1,11 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { stripHtml } from "@vitnode/core/lib/strip-html"; +import { richTextToPlainText } from "@vitnode/core/content/rich-text"; +import { + findLangValue, + getLangValue, +} from "@vitnode/core/lib/helpers/multi-lang"; export const TRANSLATED_FIELDS = [ "title", @@ -25,19 +29,30 @@ export const RECOMMENDED_LENGTH = { title: 60, } as const; -export type ArticleValues = Partial> & { +/** Every translated field except the article body, which is a document. */ +export type ArticleTextField = Exclude; + +export type ArticleValues = Partial< + Record +> & { + content?: MultiLangValue; coverImage?: unknown; }; +/** The article body in one language, if that language has one. */ +export const articleContent = ( + values: ArticleValues, + locale: string, +): null | RichTextDocument => findLangValue(values.content, locale) ?? null; + export const fieldText = ( values: ArticleValues, field: TranslatedField, locale: string, -): string => { - const value = getLangValue(values[field], locale); - - return field === "content" ? stripHtml(value).trim() : value.trim(); -}; +): string => + field === "content" + ? richTextToPlainText(articleContent(values, locale)) + : getLangValue(values[field], locale).trim(); export const hasFieldText = ( values: ArticleValues, From 51f8877b38b93d97b54019e91c9087899fcc42c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:04:08 +0000 Subject: [PATCH 08/25] =?UTF-8?q?feat(db):=20=F0=9F=97=83=EF=B8=8F=20add?= =?UTF-8?q?=20live=20editing=20tables=20and=20store=20blog=20content=20as?= =?UTF-8?q?=20rich=20text=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blog content column moves from HTML text to ProseMirror JSON. Existing rows keep their words as a single paragraph; a full HTML conversion ships before the stable release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../migration.sql | 65 + .../snapshot.json | 11933 ++++++++++++++++ 2 files changed, 11998 insertions(+) create mode 100644 apps/api/migrations/20261008100325_add_content_live_editing/migration.sql create mode 100644 apps/api/migrations/20261008100325_add_content_live_editing/snapshot.json diff --git a/apps/api/migrations/20261008100325_add_content_live_editing/migration.sql b/apps/api/migrations/20261008100325_add_content_live_editing/migration.sql new file mode 100644 index 0000000000..199576a0d6 --- /dev/null +++ b/apps/api/migrations/20261008100325_add_content_live_editing/migration.sql @@ -0,0 +1,65 @@ +CREATE TABLE "core_content_documents" ( + "id" serial PRIMARY KEY, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "field" varchar(100) NOT NULL, + "language" varchar(35) DEFAULT '' NOT NULL, + "state" bytea NOT NULL, + "baseVersion" integer, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "updatedBy" integer +); +--> statement-breakpoint +ALTER TABLE "core_content_documents" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_content_drafts" ( + "id" serial PRIMARY KEY, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "language" varchar(32) DEFAULT '' NOT NULL, + "values" jsonb DEFAULT '{}' NOT NULL, + "baseVersion" integer DEFAULT 0 NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "updatedBy" integer +); +--> statement-breakpoint +ALTER TABLE "core_content_drafts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_content_field_locks" ( + "id" serial PRIMARY KEY, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "field" varchar(100) NOT NULL, + "language" varchar(32) DEFAULT '' NOT NULL, + "userId" integer NOT NULL, + "acquiredAt" timestamp DEFAULT now() NOT NULL, + "expiresAt" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_field_locks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ALTER COLUMN "content" SET DATA TYPE jsonb USING ( + CASE + WHEN "content" IS NULL THEN NULL + WHEN btrim(regexp_replace("content", '<[^>]*>', ' ', 'g')) = '' THEN '{"type":"doc","content":[]}'::jsonb + ELSE jsonb_build_object( + 'type', 'doc', + 'content', jsonb_build_array(jsonb_build_object( + 'type', 'paragraph', + 'content', jsonb_build_array(jsonb_build_object( + 'type', 'text', + 'text', btrim(regexp_replace(regexp_replace("content", '<[^>]*>', ' ', 'g'), '\s+', ' ', 'g')) + )) + )) + ) + END +);--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_documents_doc_unique" ON "core_content_documents" ("contentTypeId","itemId","field","language");--> statement-breakpoint +CREATE INDEX "core_content_documents_updated_at_idx" ON "core_content_documents" ("updatedAt");--> statement-breakpoint +CREATE INDEX "core_content_documents_updated_by_idx" ON "core_content_documents" ("updatedBy");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_drafts_key_unique" ON "core_content_drafts" ("contentTypeId","itemId","language");--> statement-breakpoint +CREATE INDEX "core_content_drafts_updated_at_idx" ON "core_content_drafts" ("updatedAt");--> statement-breakpoint +CREATE INDEX "core_content_drafts_updated_by_idx" ON "core_content_drafts" ("updatedBy");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_field_locks_key_unique" ON "core_content_field_locks" ("contentTypeId","itemId","field","language");--> statement-breakpoint +CREATE INDEX "core_content_field_locks_expires_at_idx" ON "core_content_field_locks" ("expiresAt");--> statement-breakpoint +CREATE INDEX "core_content_field_locks_user_id_idx" ON "core_content_field_locks" ("userId");--> statement-breakpoint +ALTER TABLE "core_content_documents" ADD CONSTRAINT "core_content_documents_updatedBy_core_users_id_fkey" FOREIGN KEY ("updatedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "core_content_drafts" ADD CONSTRAINT "core_content_drafts_updatedBy_core_users_id_fkey" FOREIGN KEY ("updatedBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "core_content_field_locks" ADD CONSTRAINT "core_content_field_locks_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261008100325_add_content_live_editing/snapshot.json b/apps/api/migrations/20261008100325_add_content_live_editing/snapshot.json new file mode 100644 index 0000000000..1d3573e562 --- /dev/null +++ b/apps/api/migrations/20261008100325_add_content_live_editing/snapshot.json @@ -0,0 +1,11933 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "fe5da61d-7cd8-4945-ac5d-34cc948e5bdc", + "prevIds": [ + "5fef0d7f-7907-4111-9e85-790f416a05ff", + "bb799038-c9f7-4c6a-83ac-68898c684511" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file From 6d80f17a92697831301c3d38e1c809958088204f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:05:27 +0000 Subject: [PATCH 09/25] =?UTF-8?q?docs(content):=20=F0=9F=93=9D=20document?= =?UTF-8?q?=20live=20editing=20and=20autosave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../docs/dev/content-engine/live-editing.mdx | 116 ++++++++++++++++++ .../content/docs/dev/content-engine/meta.json | 1 + .../publication-and-editorial.mdx | 2 + 3 files changed, 119 insertions(+) create mode 100644 apps/web/content/docs/dev/content-engine/live-editing.mdx diff --git a/apps/web/content/docs/dev/content-engine/live-editing.mdx b/apps/web/content/docs/dev/content-engine/live-editing.mdx new file mode 100644 index 0000000000..b9a733305d --- /dev/null +++ b/apps/web/content/docs/dev/content-engine/live-editing.mdx @@ -0,0 +1,116 @@ +--- +title: Live editing and autosave +description: Let several editors work on one Content Engine record at once. Autosave to a shared draft, see who is editing which field, lock plain fields and co-edit rich text in real time with Yjs. +icon: Users +--- + +Live editing lets a whole team work on the same record at the same time without stepping on each other. Every editorial content type gets it automatically in the AdminCP: + +- **Autosave.** Changes are saved to a shared draft a moment after you stop typing. Nothing reaches the published record until someone presses **Save** or **Publish**. +- **Presence.** Avatars show who has the record open, which field they are in and which language they are editing. +- **Field locks.** A plain field, such as a title, slug or category, is edited by one person at a time. Everyone else sees it read-only, with the editor's name, and gets the new value live. +- **Co-editing.** A [rich text](/docs/dev/content-engine/rich-text) field is the exception: several people type in it at once and see each other's cursors. + +## Turn it on + +Live editing needs an [editorial content type](/docs/dev/content-engine/publication-and-editorial). There is nothing else to configure: + +```ts title="plugins/blog/src/content/post.ts" +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + editorial: { enabled: true }, // [!code highlight] + fields: { + title: field.text({ localized: true, required: true }), + content: field.richText({ localized: true, required: true }), + }, +}); +``` + +Open an existing record in the AdminCP and you are in a live session. A form that creates a new record is not live yet, because there is no record to share. + +Everyone in the session needs the content type's `can_edit` permission. Viewing a record without it does not join the session. + +## What editors see + +### Autosave and the shared draft + +About 1.5 seconds after you stop typing, your changes are saved to the record's draft. The form header shows the state: **Saving…**, **Draft saved at 14:32**, or **Unsaved changes** when the draft differs from the published record. + +There is one draft per record and language, shared by the whole team. It is the team's working copy: when you open the record, you see the draft, not only what was last saved. **Save** commits everything in the draft through the usual [versioned save](/docs/dev/content-engine/publication-and-editorial#safe-concurrent-edits), creating one revision. Autosave never creates revisions, never emits content events and never touches search or caches. + +### Field locks + +Clicking into a plain field takes a lock on that field, in that language. The lock lasts 60 seconds and is renewed every 20 seconds while you stay in the field. Leaving the field gives it back. + +While Anna holds the title, everyone else sees the title read-only with **Anna is editing**, and the value updates live as she types. When she moves on, the field becomes editable again. + +A lock that is not renewed, because a laptop went to sleep or a tab crashed, expires on its own after 60 seconds. + +### Co-editing rich text + +Rich text fields are not locked. Everyone in the same field and language types into one shared document, and each person's cursor and selection show with their name and color. Undo only undoes your own changes. + +The shared document is saved on the server about two seconds after the last change, so it is autosaved too. **Save** writes its current content to the record as JSON. + +### Resets + +Restoring a revision or deleting the record throws the drafts, locks and shared documents away. Everyone in the session gets a notice and their form reloads with the new state. + +## Deployment requirements + +Presence and co-editing travel over the [WebSocket](/docs/dev/websocket) at `/api/ws`, so they need a long-lived Node server, such as the separate `apps/api` server or a [self-hosted](/docs/dev/deployments/self-hosted) install. + +| Deployment | Autosave and locks | Presence | Rich text | +| --- | --- | --- | --- | +| Long-lived Node server | Live | Yes | Co-edited | +| Serverless, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Checked every 10 seconds | No | One editor at a time, locked like a plain field | + +With more than one API instance, set `REDIS_URL`. Instances share presence and shared-document updates over Redis pub/sub; without it, editors connected to different instances do not see each other. + +## Where the data lives + +| Table | Holds | +| --- | --- | +| `core_content_drafts` | The shared draft of plain fields, one row per record and language | +| `core_content_field_locks` | Active field locks, with their holder and expiry | +| `core_content_documents` | The Yjs state of each rich text field, per record and language | + +The daily `content-editorial-cleanup` cron removes expired locks, and drafts and documents nobody touched for 30 days. A stale draft whose record has not changed since is kept, because it is somebody's unsaved work. + +## HTTP API + +The AdminCP calls these routes for you. They are mounted under the content type's admin module, for example `/api/@vitnode/blog/admin/content/posts`, and all of them need `can_edit`. + +| Method and path | Body | Result | +| --- | --- | --- | +| `GET /{id}/locks` | | `{ locks }`: unexpired locks of the record | +| `POST /{id}/locks` | `{ field, locale, action }` with `action` `acquire`, `renew` or `release` | `{ lock }`, or `409` `CONTENT_FIELD_LOCKED` with the current holder | +| `GET /{id}/draft` | | `{ shared, translations }`: the shared draft and one draft per language | +| `PUT /{id}/draft` | `{ locale, values }` | `{ updatedAt }`, or `409` `CONTENT_FIELD_NOT_LOCKED` for fields you do not hold | + +`locale` is `null` for a field that is not localized and the language code otherwise. A draft write only accepts fields you currently hold a lock on, and validates each value with the field's own rules. + +## Use live state in a custom form layout + +A plugin that replaces the edit form [with its own layout](/docs/dev/content-engine/admincp) can read the session from `@vitnode/core/content/admin-form`: + +```tsx title="plugins/blog/src/views/admin/article/editor/header.tsx" +import { + ContentLiveStatus, + useContentLive, +} from "@vitnode/core/content/admin-form"; + +export const ArticleHeader = () => { + const live = useContentLive(); + + return ( +
+ + {live ? {live.session.members.length} : null} +
+ ); +}; +``` + +`useContentLive()` returns `null` outside a live form, so the same layout works for the create form. diff --git a/apps/web/content/docs/dev/content-engine/meta.json b/apps/web/content/docs/dev/content-engine/meta.json index 2c377d1232..92eba355a6 100644 --- a/apps/web/content/docs/dev/content-engine/meta.json +++ b/apps/web/content/docs/dev/content-engine/meta.json @@ -12,6 +12,7 @@ "relations-and-advanced-modeling", "localization", "publication-and-editorial", + "live-editing", "---Customize---", "admincp", "services-and-api", diff --git a/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx b/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx index e5230cf798..5bc95dd5e9 100644 --- a/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx +++ b/apps/web/content/docs/dev/content-engine/publication-and-editorial.mdx @@ -41,6 +41,8 @@ Each editorial write raises the record's `version` by one and only succeeds when Calls to the edit routes must send it too. `PUT /api/@vitnode/example/admin/content/recipe/{id}` takes `{ "expectedVersion": 3, "values": { "title": "…" } }`, and `DELETE` takes `{ "expectedVersion": 3 }`. In your own server code, use `recipeContent.editorialService` to get the same check; see [Services, schemas and events](/docs/dev/content-engine/services-and-api#editorial-writes). +In the AdminCP, editors rarely hit this conflict: [live editing](/docs/dev/content-engine/live-editing) autosaves to a shared draft and locks each field to one editor at a time. + ## Preview links A preview link lets a reviewer read a draft without signing in. Previews need `publicApi`, because the preview shows the same fields as the public API. Links are HMAC-signed and expire after `expiresInMinutes`, from 1 to 1440 minutes, 15 by default. From 34f53c5cbff511a69907c6e809284fc25aafebd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:36:34 +0000 Subject: [PATCH 10/25] =?UTF-8?q?feat(content):=20=E2=9C=A8=20add=20collab?= =?UTF-8?q?orative=20rich=20text=20editor=20and=20presence=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Yjs document provider over the live channel: opens on every (re)join and on NOT_JOINED, exchanges state vectors, relays awareness, seeds once, goes quiet on reset, filters by document and clientId for tabs sharing a socket - Co-edited rich text editor (lazy): Collaboration + CollaborationCaret with carets in the member's avatar color, Yjs undo, skeleton until synced, form value follows the shared document; no field lock and no draft autosave while co-editing (sticky through a dropped socket) - Presence UI: ContentLivePresence (header, page and dialog), field avatars beside labels, language markers on MultiLangSelect - Editor registry (useContentRichTextEditor / useContentRichTextReplace) so layouts write through the shared editor - Field locks follow the language a field's own switcher shows; focus keeps the editor's language for shared fields - Session re-joins on NOT_JOINED instead of falling back to polling, and a document's error no longer costs the seat Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../docs/dev/events/built-in-events.mdx | 7 + .../src/components/form/common/label-addon.ts | 9 + .../src/components/form/common/label.tsx | 9 + .../src/components/form/fields/editor.tsx | 52 ++- .../form/fields/multi-lang-presence.ts | 21 ++ .../src/components/form/fields/multi-lang.tsx | 22 +- .../src/components/tiptap/collaboration.ts | 36 ++ .../src/components/tiptap/tiptap-editor.tsx | 25 +- packages/vitnode/src/locales/en.json | 10 + packages/vitnode/src/tests/content-live.ts | 19 +- packages/vitnode/src/tiptap.css | 10 + .../views/content/actions/content-form.tsx | 8 +- .../views/admin/views/content/form/index.ts | 10 + .../views/content/form/layout-primitives.tsx | 6 +- .../live/collaborative-editor.test.tsx | 291 +++++++++++++++ .../content/live/collaborative-editor.tsx | 265 +++++++++++++ .../views/admin/views/content/live/context.ts | 10 + .../views/content/live/doc-provider.test.ts | 353 ++++++++++++++++++ .../admin/views/content/live/doc-provider.ts | 344 +++++++++++++++++ .../admin/views/content/live/field.test.tsx | 262 ++++++++++++- .../views/admin/views/content/live/field.tsx | 139 ++++++- .../views/content/live/presence-model.ts | 116 ++++++ .../views/content/live/presence.test.tsx | 253 +++++++++++++ .../admin/views/content/live/presence.tsx | 228 +++++++++++ .../views/content/live/rich-text-field.tsx | 43 +++ .../views/content/live/rich-text.test.ts | 52 +++ .../admin/views/content/live/rich-text.ts | 125 +++++++ .../views/admin/views/content/live/root.tsx | 9 + .../views/content/live/use-session.test.tsx | 109 +++++- .../admin/views/content/live/use-session.ts | 42 ++- 30 files changed, 2828 insertions(+), 57 deletions(-) create mode 100644 packages/vitnode/src/components/form/common/label-addon.ts create mode 100644 packages/vitnode/src/components/form/fields/multi-lang-presence.ts create mode 100644 packages/vitnode/src/components/tiptap/collaboration.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/collaborative-editor.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/doc-provider.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/doc-provider.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/presence-model.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/presence.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/presence.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/live/rich-text.ts diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 4f1f64b72a..884fc77689 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -1176,6 +1176,13 @@ issue or PR - adding an event is a one-line `emit` plus an entry in the `VitNodeEvents` map. (`user.deleted` is the special case: it is already on the map, and waits on core growing a user-deletion flow.) +Live editing is quiet on the bus too. Taking or giving back a field lock, +autosaving the shared draft and every keystroke in a co-edited rich text field +happen many times a minute and change nothing anyone outside the form relies +on, so none of them is an event. What does reach the bus is the commit: when +someone presses **Save**, the record emits its usual `updated` (and its +translations theirs), exactly as it would without live editing. + ## Next diff --git a/packages/vitnode/src/components/form/common/label-addon.ts b/packages/vitnode/src/components/form/common/label-addon.ts new file mode 100644 index 0000000000..505b1045df --- /dev/null +++ b/packages/vitnode/src/components/form/common/label-addon.ts @@ -0,0 +1,9 @@ +import React from "react"; + +/** + * Something shown beside a field's label - who else is in the field, in a live + * form. Decorative: the field tells assistive technology the same thing some + * other way. + */ +export const AutoFormLabelAddonContext = + React.createContext(null); diff --git a/packages/vitnode/src/components/form/common/label.tsx b/packages/vitnode/src/components/form/common/label.tsx index 5cbe184467..c946d0befa 100644 --- a/packages/vitnode/src/components/form/common/label.tsx +++ b/packages/vitnode/src/components/form/common/label.tsx @@ -1,9 +1,12 @@ import { cn } from "cn"; +import React from "react"; import { useTranslations } from "use-intl"; import { FieldLabel } from "@/components/ui/field"; import { useFormField } from "@/components/ui/form"; +import { AutoFormLabelAddonContext } from "./label-addon"; + export const AutoFormLabel = ({ children, labelRight, @@ -16,6 +19,7 @@ export const AutoFormLabel = ({ }) => { const t = useTranslations("core.global"); const { formItemId } = useFormField(); + const addon = React.use(AutoFormLabelAddonContext); return ( {children} + {addon ? ( + + {addon} + + ) : null} {isOptional && ( {t("optional")} )} diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx index bb6fc1a6e5..6607f76cf9 100644 --- a/packages/vitnode/src/components/form/fields/editor.tsx +++ b/packages/vitnode/src/components/form/fields/editor.tsx @@ -1,10 +1,14 @@ -import type React from "react"; +import React from "react"; import type { TipTapEditorBaseProps } from "@/components/tiptap/tiptap-editor"; import type { FormFieldApi } from "@/components/ui/form"; import type { RichTextDocument } from "@/content/rich-text/document"; import type { MultiLangValue } from "@/lib/helpers/multi-lang"; +import { + CollaborativeEditorSlot, + EditorCollaborationContext, +} from "@/components/tiptap/collaboration"; import { Editor } from "@/components/ui/editor"; import { FormControl, FormMessage, useFormField } from "@/components/ui/form"; import { isRichTextEmpty } from "@/content/rich-text/document"; @@ -162,6 +166,7 @@ const MultiLangJsonEditor = ({ isFilled: hasDocumentContent, }); const labelledBy = useEditorLabelledBy(label); + const collaborative = React.use(EditorCollaborationContext); return ( - + {collaborative ? ( + // Its own shared document per language: remounted like the editor. + + ) : ( + + )} ); }; @@ -200,6 +219,7 @@ export const AutoFormEditor = ({ ...props }: AutoFormEditorProps) => { const labelledBy = useEditorLabelledBy(label); + const collaborative = React.use(EditorCollaborationContext); if (multiLang && format === "json") { return ( @@ -236,7 +256,17 @@ export const AutoFormEditor = ({ )} - {format === "json" ? ( + {format === "json" && collaborative ? ( + + ) : format === "json" ? ( boolean; + /** The marker shown beside the language's name, `null` when nobody is in it. */ + marker: (code: string) => React.ReactNode; +} + +/** Set by a live form; without it the language switcher shows nothing extra. */ +export const MultiLangPresenceContext = + React.createContext(null); + +/** + * Hears which language a multi-language field shows, so a wrapper (a live + * form's field lock) knows the language without owning the switcher. + */ +export const MultiLangShownLanguageContext = React.createContext< + ((code: string) => void) | null +>(null); diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx index 861905ecaf..36dbc5b4b2 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang.tsx +++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx @@ -22,6 +22,10 @@ import { import { useMultiLangDefaultLanguage } from "./multi-lang-default-language"; import { useMultiLangLanguage } from "./multi-lang-language"; +import { + MultiLangPresenceContext, + MultiLangShownLanguageContext, +} from "./multi-lang-presence"; export { multiLangValueSchema } from "@/lib/helpers/multi-lang"; export type { @@ -49,9 +53,15 @@ const useMultiLangSelection = ( }), ); + const language = lockedLanguage ?? selected; + const reportShown = React.use(MultiLangShownLanguageContext); + React.useEffect(() => { + reportShown?.(language); + }, [language, reportShown]); + return { canSelect: lockedLanguage === null && languages.length > 1, - language: lockedLanguage ?? selected, + language, languages, setSelected, }; @@ -119,6 +129,12 @@ export const MultiLangSelect = ({ selected: string; }) => { const t = useTranslations("core.global"); + const presence = React.use(MultiLangPresenceContext); + const elsewhere = + presence !== null && + languages.some( + language => language.code !== selected && presence.busy(language.code), + ); return ( + + ); +}; + const lockOf = (userId: number, name: string): ContentFieldLock => ({ expiresAt: new Date(Date.now() + 60_000).toISOString(), field: "title", @@ -45,14 +128,28 @@ const lockOf = (userId: number, name: string): ContentFieldLock => ({ user: { id: userId, name }, }); -const setup = ({ locks = [] }: { locks?: ContentFieldLock[] } = {}) => { +const setup = ({ + children = , + coEditing = false, + locks = [], + members = [], + target = fieldSpec, + value = "Hello", +}: { + children?: React.ReactNode; + coEditing?: boolean; + locks?: ContentFieldLock[]; + members?: ContentLiveMember[]; + target?: ContentFormFieldSpec; + value?: unknown; +} = {}) => { const draftListeners = new Set<(event: ContentLiveDraftEvent) => void>(); const session: ContentLiveSession = { clientId: "tab-1", focus: vi.fn(), - live: false, + live: coEditing, locks, - members: [], + members, onDraft: listener => { draftListeners.add(listener); @@ -76,8 +173,10 @@ const setup = ({ locks = [] }: { locks?: ContentFieldLock[] } = {}) => { }), queue: vi.fn(), }, + coEditing, itemId: 7, - locale: null, + locale: target.localized === true ? "en" : null, + richText: createContentRichTextRegistry(), session, spec, status: { dirty: false, failed: false, savedAt: null, saving: false }, @@ -91,24 +190,26 @@ const setup = ({ locks = [] }: { locks?: ContentFieldLock[] } = {}) => { ); const transport = { lock } as unknown as ContentFormTransport; const field: AnyFormFieldApi = { - name: "title", + name: target.name, onBlur: vi.fn(), onChange: vi.fn(), - value: "Hello", + value, }; - render( + const view = render( - - - - - - + + + + {children} + + + + , ); - return { draftListeners, field, live, lock }; + return { draftListeners, field, live, lock, view }; }; afterEach(() => { @@ -228,4 +329,133 @@ describe("ContentLiveField", () => { expect(field.onChange).not.toHaveBeenCalled(); }); + + it("locks the language its switcher shows, and moves the lock with it", async () => { + const { live, lock } = setup({ + children: , + target: localizedTitle, + }); + + fireEvent.click(screen.getByRole("button", { name: "Polski" })); + await act(async () => { + fireEvent.focus(screen.getByRole("textbox", { name: "Title" })); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(lock).toHaveBeenCalledWith("test.note", 7, { + action: "acquire", + field: "title", + locale: "pl", + }); + }); + expect(live.session.focus).toHaveBeenLastCalledWith("title", "pl"); + + // Still in the field, the editor switches back to English. + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "English" })); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(lock).toHaveBeenCalledWith("test.note", 7, { + action: "release", + field: "title", + locale: "pl", + }); + }); + await vi.waitFor(() => { + expect(lock).toHaveBeenLastCalledWith("test.note", 7, { + action: "acquire", + field: "title", + locale: "en", + }); + }); + }); + + it("shows who else is in the field, in the language shown", () => { + setup({ + children: , + coEditing: true, + members: [ + memberOf(ANNA, "Anna", { field: "title", locale: "pl" }), + memberOf(3, "Ben", { field: "title", locale: "en" }), + ], + target: localizedTitle, + }); + + const addon = screen.getByTestId("label-addon"); + expect(within(addon).getByTitle("Ben")).toBeTruthy(); + expect(within(addon).queryByTitle("Anna")).toBeNull(); + expect( + screen.getByRole("group", { + description: "core.content.live.presence.field", + }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Polski/ })); + + expect(within(addon).getByTitle("Anna")).toBeTruthy(); + expect(within(addon).queryByTitle("Ben")).toBeNull(); + }); + + it("marks the languages someone else is editing on the switcher", () => { + setup({ + children: , + coEditing: true, + members: [memberOf(ANNA, "Anna", { field: "title", locale: "pl" })], + target: localizedTitle, + }); + + const polish = screen.getByRole("button", { name: /Polski/ }); + expect(polish.textContent).toContain("(busy)"); + expect(within(polish).getByTitle("Anna")).toBeTruthy(); + expect( + screen.getByRole("button", { name: /English/ }).textContent, + ).not.toContain("(busy)"); + }); + + it("co-edits rich text: no lock, no draft autosave, the shared editor", async () => { + const { draftListeners, field, live, lock, view } = setup({ + children: ( + <> + + + + ), + coEditing: true, + target: content, + value: [{ languageCode: "en", value: { type: "doc" } }], + }); + + expect(screen.getByText("shared editor")).toBeTruthy(); + + await act(async () => { + fireEvent.focus(screen.getByRole("textbox", { name: "Content" })); + await Promise.resolve(); + }); + expect(lock).not.toHaveBeenCalled(); + expect(live.session.focus).toHaveBeenCalledWith("content", "en"); + + // Someone's autosaved draft never lands in a co-edited document. + act(() => { + for (const listener of draftListeners) { + listener({ + by: { id: ANNA, name: "Anna" }, + locale: "en", + updatedAt: new Date().toISOString(), + values: { content: { content: [], type: "doc" } }, + }); + } + }); + expect(field.onChange).not.toHaveBeenCalled(); + view.unmount(); + expect(live.autosave.queue).not.toHaveBeenCalled(); + }); + + it("keeps the local editor for rich text without a socket", () => { + setup({ children: , target: content, value: [] }); + + expect(screen.getByText("local editor")).toBeTruthy(); + }); }); diff --git a/packages/vitnode/src/views/admin/views/content/live/field.tsx b/packages/vitnode/src/views/admin/views/content/live/field.tsx index 3ac3c04050..6a68e1e8e1 100644 --- a/packages/vitnode/src/views/admin/views/content/live/field.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/field.tsx @@ -6,7 +6,14 @@ import type { ContentFormFieldSpec } from "@/content/admin/spec"; import type { ContentFieldLock } from "@/content/live/protocol"; import { Avatar } from "@/components/avatar"; +import { AutoFormLabelAddonContext } from "@/components/form/common/label-addon"; import { useMultiLangLanguage } from "@/components/form/fields/multi-lang-language"; +import { + type MultiLangPresence, + MultiLangPresenceContext, + MultiLangShownLanguageContext, +} from "@/components/form/fields/multi-lang-presence"; +import { EditorCollaborationContext } from "@/components/tiptap/collaboration"; import { contentApiValueToFormField, contentFormFieldToApi, @@ -17,6 +24,19 @@ import type { ContentLiveContextValue } from "./context"; import { useContentFormTransport } from "../form/transport"; import { useContentLive } from "./context"; +import { + ContentLiveFieldPresence, + ContentLiveLanguagePresence, + useContentLiveNames, +} from "./presence"; +import { + contentLiveFieldEditors, + contentLiveLanguageEditors, +} from "./presence-model"; +import { + ContentLiveRichTextFieldContext, + renderContentLiveCollaborativeEditor, +} from "./rich-text-field"; /** * How long a field keeps its lock after focus leaves it. A select or a picker @@ -25,7 +45,6 @@ import { useContentLive } from "./context"; */ export const CONTENT_FIELD_LOCK_BLUR_GRACE_MS = 1_500; -/** `richText` is not a field kind yet: it arrives with the rich text phase. */ const isRichText = (kind: string): boolean => kind === "richText"; const isExpired = (lock: ContentFieldLock): boolean => @@ -89,18 +108,28 @@ const LiveField = ({ fieldSpec: ContentFormFieldSpec; live: ContentLiveContextValue; }) => { + const t = useTranslations("core.content.live.presence"); + const names = useContentLiveNames(); const transport = useContentFormTransport(); const pinned = useMultiLangLanguage(); const badgeId = React.useId(); + const presenceId = React.useId(); const { autosave, session, spec } = live; const name = fieldSpec.name; - const locale = fieldSpec.localized === true ? (pinned ?? live.locale) : null; - // Rich text is co-edited through Yjs while the socket is live, so everyone - // types in it at once and it takes no lock; without a socket it locks. - const lockable = !(isRichText(fieldSpec.kind) && session.live); + const localized = fieldSpec.localized === true; + /** The language the field's own switcher shows, when it has one. */ + const [shown, setShown] = React.useState(null); + const locale = localized ? (pinned ?? shown ?? live.locale) : null; + // Rich text is co-edited through Yjs once the socket let this tab in, so + // everyone types in it at once and it takes no lock - and its shared + // document is its autosave. Without a socket it is a locked field. + const coEdited = isRichText(fieldSpec.kind) && live.coEditing; + const lockable = !coEdited; const [held, setHeld] = React.useState(false); const heldRef = React.useRef(false); + /** The language of the lock held: the switcher may have moved on since. */ + const heldLocaleRef = React.useRef(null); const busyRef = React.useRef>(Promise.resolve()); const releaseTimerRef = React.useRef>(undefined); @@ -122,21 +151,26 @@ const LiveField = ({ setHeld(value); }; - const request = async (action: "acquire" | "release" | "renew") => + const request = async ( + action: "acquire" | "release" | "renew", + lockLocale: null | string, + ) => await transport.lock(spec.contentTypeId, live.itemId, { action, field: name, - locale, + locale: lockLocale, }); const acquire = async () => { if (heldRef.current || !lockable) return; - const result = await request("acquire"); + const target = locale; + const result = await request("acquire", target); if (result.lock) { session.rememberSelf(result.lock.user.id); + heldLocaleRef.current = target; markHeld(focusedRef.current); - if (!focusedRef.current) await request("release"); + if (!focusedRef.current) await request("release", target); return; } @@ -149,13 +183,13 @@ const LiveField = ({ await autosave.flush(); markHeld(false); - await request("release"); + await request("release", heldLocaleRef.current); }; const renew = async () => { if (!heldRef.current) return; - const result = await request("renew"); + const result = await request("renew", heldLocaleRef.current); if (result.lock) return; // The lease ran out and someone else took it, or it vanished. @@ -199,6 +233,16 @@ const LiveField = ({ [], ); + // Another language on the switcher is another lock: give the old one back + // and, still in the field, take the new one. + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- the language moves inside the field's own switcher, which knows nothing of locks + if (!heldRef.current || heldLocaleRef.current === locale) return; + + run("release"); + if (focusedRef.current) run("acquire"); + }, [locale]); + // Autosave what this person types, while the field is theirs - from the // first change after taking the lock, so merely visiting a field writes // nothing. @@ -207,7 +251,7 @@ const LiveField = ({ /** The value when focus arrived, which can be before the lock did. */ const focusValueRef = React.useRef(null); const queueChange = React.useEffectEvent(() => { - if (!held) { + if (!held || !lockable) { baselineRef.current = null; return; @@ -232,7 +276,8 @@ const LiveField = ({ // Everyone else's autosaves land in this field - never while it is ours. const applyDraft = React.useEffectEvent( (event: { locale: null | string; values: Record }) => { - if (!(name in event.values)) return; + // A co-edited document arrives through Yjs, never through the draft. + if (!lockable || !(name in event.values)) return; if (heldRef.current && event.locale === locale) return; if ( fieldSpec.localized === true @@ -269,24 +314,86 @@ const LiveField = ({ if (next instanceof Node && event.currentTarget.contains(next)) return; focusedRef.current = false; - session.focus(null, null); + session.focus(null, locale); clearTimeout(releaseTimerRef.current); releaseTimerRef.current = setTimeout(() => { if (!focusedRef.current) run("release"); }, CONTENT_FIELD_LOCK_BLUR_GRACE_MS); }; + const editors = session.live + ? contentLiveFieldEditors(session.members, session, { field: name, locale }) + : []; + const describedBy = + [lockedByOther ? badgeId : null, editors.length > 0 ? presenceId : null] + .filter(Boolean) + .join(" ") || undefined; + const languagePresence: MultiLangPresence | null = + localized && session.live + ? { + busy: code => + contentLiveLanguageEditors(session.members, session, { + field: name, + locale: code, + }).length > 0, + marker: code => ( + + ), + } + : null; + + let content = children; + if (coEdited) { + content = ( + + + {content} + + + ); + } + return (
- {children} + + + 0 ? ( + + ) : null + } + > + {content} + + +
+ {editors.length > 0 ? ( + + {t("field", { + count: editors.length, + names: names(editors), + })} + + ) : null} { + const match = HEX_COLOR.exec(avatarColor?.trim() ?? ""); + + return `#${(match?.[1] ?? CONTENT_LIVE_FALLBACK_COLOR).toLowerCase()}`; +}; + +const channel = (value: number): number => { + const srgb = value / 255; + + return srgb <= 0.039_28 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; +}; + +const luminance = (hex: string): number => { + const value = Number.parseInt(hex.slice(1), 16); + + return ( + 0.2126 * channel((value >> 16) & 255) + + 0.7152 * channel((value >> 8) & 255) + + 0.0722 * channel(value & 255) + ); +}; + +/** + * Black or white, whichever reads better on `background` (`#rrggbb`) - the + * text of a caret label sits on the member's own color. + */ +export const contentLiveLabelColor = (background: string): string => { + const light = luminance(background); + const onWhite = 1.05 / (light + 0.05); + const onBlack = (light + 0.05) / 0.05; + + return onBlack >= onWhite ? "#0a0a0a" : "#ffffff"; +}; + +export interface ContentLiveViewer { + /** This tab. */ + clientId: string; + /** This person, once known. */ + self: null | number; +} + +/** Everyone but this person - in any of their tabs. */ +const othersOnly = ( + members: readonly ContentLiveMember[], + { clientId, self }: ContentLiveViewer, +): ContentLiveMember[] => { + const me = + members.find(member => member.clientId === clientId)?.userId ?? self; + + return members.filter( + member => member.clientId !== clientId && member.userId !== me, + ); +}; + +/** + * One entry per person: a second tab of the same editor is not a second + * editor. The tab with a field in focus speaks for the person. + */ +const perPerson = (members: readonly ContentLiveMember[]) => { + const people = new Map(); + for (const member of members) { + const known = people.get(member.userId); + if (!known || (known.field === null && member.field !== null)) { + people.set(member.userId, member); + } + } + + return [...people.values()]; +}; + +/** Everyone else in the record's room, once each. */ +export const contentLiveOthers = ( + members: readonly ContentLiveMember[], + viewer: ContentLiveViewer, +): ContentLiveMember[] => perPerson(othersOnly(members, viewer)); + +/** + * The other people in one field. A localized field counts only the language + * shown (`locale`); a shared field (`null`) is the same in every language. + */ +export const contentLiveFieldEditors = ( + members: readonly ContentLiveMember[], + viewer: ContentLiveViewer, + { field, locale }: { field: string; locale: null | string }, +): ContentLiveMember[] => + perPerson( + othersOnly(members, viewer).filter( + member => + member.field === field && (locale === null || member.locale === locale), + ), + ); + +/** + * The other people working in one language - in any field, or in `field` + * only when it is given. + */ +export const contentLiveLanguageEditors = ( + members: readonly ContentLiveMember[], + viewer: ContentLiveViewer, + { field, locale }: { field?: string; locale: string }, +): ContentLiveMember[] => + perPerson( + othersOnly(members, viewer).filter( + member => + member.locale === locale && + (field === undefined || member.field === field), + ), + ); diff --git a/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx b/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx new file mode 100644 index 0000000000..4d96caa8e2 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx @@ -0,0 +1,253 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentLiveMember } from "@/content/live/protocol"; + +import type { ContentLiveContextValue } from "./context"; + +import { ContentLiveContext } from "./context"; +import { + ContentLiveFieldPresence, + ContentLiveLanguagePresence, + ContentLivePresence, +} from "./presence"; +import { createContentRichTextRegistry } from "./rich-text"; + +const spec: ContentFormSpec = { + contentTypeId: "test.note", + defaultLocale: "en", + editorial: true, + fields: [ + { + kind: "text", + label: "Title", + localized: true, + name: "title", + nullable: false, + required: true, + }, + { + kind: "relation", + label: "Category", + name: "categoryId", + nullable: true, + required: false, + }, + ], + permissionModule: "notes", + pluginId: "@vitnode/example", + sections: [], + titleField: "title", +}; + +let nextUser = 10; +const member = ( + name: string, + overrides: Partial = {}, +): ContentLiveMember => { + nextUser += 1; + + return { + avatarColor: "2563eb", + clientId: `${name}-tab`, + field: null, + locale: "en", + name, + nameCode: name, + userId: nextUser, + ...overrides, + }; +}; + +const ME: ContentLiveMember = { + avatarColor: null, + clientId: "my-tab", + field: "title", + locale: "en", + name: "Me", + nameCode: "Me", + userId: 1, +}; + +const renderLive = ( + ui: React.ReactNode, + { live = true, members }: { live?: boolean; members: ContentLiveMember[] }, +) => { + const value: ContentLiveContextValue = { + autosave: { flush: vi.fn(), queue: vi.fn() }, + coEditing: live, + itemId: 7, + locale: "en", + richText: createContentRichTextRegistry(), + session: { + clientId: ME.clientId, + focus: vi.fn(), + live, + locks: [], + members: [ME, ...members], + onDraft: () => () => {}, + onReset: () => () => {}, + readDrafts: async () => await Promise.resolve(null), + readyState: live ? 1 : 3, + refreshLocks: vi.fn(), + rememberSelf: vi.fn(), + self: ME.userId, + }, + spec, + status: { dirty: false, failed: false, savedAt: null, saving: false }, + }; + + return render({ui}); +}; + +describe("ContentLivePresence", () => { + it("shows everyone else in the record, with where they are", () => { + renderLive(, { + members: [ + member("Anna", { field: "title", locale: "pl" }), + member("Ben", { field: "categoryId" }), + member("Cleo"), + ], + }); + + const group = screen.getByRole("group", { + name: "core.content.live.presence.label", + }); + expect( + within(group) + .getAllByRole("img") + .map(image => image.getAttribute("alt")), + ).toEqual(["Anna", "Ben", "Cleo"]); + // Anna is in a localized field, Ben in a shared one, Cleo in none. + expect( + within(group).getByText("core.content.live.presence.editing_field"), + ).toBeTruthy(); + expect( + within(group).getByText("core.content.live.presence.editing_shared"), + ).toBeTruthy(); + expect( + within(group).getByText("core.content.live.presence.in_language"), + ).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Me" })).toBeNull(); + }); + + it("shows a person with two tabs once, where they are typing", () => { + const anna = member("Anna"); + renderLive(, { + members: [ + anna, + { ...anna, clientId: "anna-second-tab", field: "title" }, + // The editor's own second tab is still the editor. + { ...ME, clientId: "my-other-tab" }, + ], + }); + + expect( + screen.getAllByRole("img").map(image => image.getAttribute("alt")), + ).toEqual(["Anna"]); + expect( + screen.getByText("core.content.live.presence.editing_field"), + ).toBeTruthy(); + }); + + it("folds everyone past the limit into +N", () => { + renderLive(, { + members: ["Anna", "Ben", "Cleo", "Dan"].map(name => member(name)), + }); + + expect(screen.getAllByRole("img")).toHaveLength(2); + expect(screen.getByText("+2")).toBeTruthy(); + expect(screen.getByText("core.content.live.presence.more")).toBeTruthy(); + }); + + it("renders nothing when the editor is alone or the form is not live", () => { + const { container } = renderLive(, { members: [] }); + expect(container.childElementCount).toBe(0); + + const offline = renderLive(, { + live: false, + members: [member("Anna")], + }); + expect(offline.container.childElementCount).toBe(0); + }); +}); + +describe("ContentLiveFieldPresence", () => { + const members = [ + member("Anna", { field: "title", locale: "pl" }), + member("Ben", { field: "title", locale: "en" }), + member("Cleo", { field: "categoryId", locale: "pl" }), + member("Dan", { field: null, locale: "pl" }), + ]; + + it("shows the people in the field, in the language shown", () => { + renderLive(, { + members, + }); + + expect(screen.getByTitle("Anna")).toBeTruthy(); + expect(screen.queryByTitle("Ben")).toBeNull(); + expect(screen.queryByTitle("Cleo")).toBeNull(); + expect(screen.getByText("core.content.live.presence.field")).toBeTruthy(); + }); + + it("counts every language for a shared field", () => { + renderLive(, { + members, + }); + + expect(screen.getByTitle("Cleo")).toBeTruthy(); + expect(screen.queryByTitle("Anna")).toBeNull(); + }); + + it("leaves the announcement to the field when decorative", () => { + renderLive( + , + { members }, + ); + + expect(screen.getByTitle("Ben")).toBeTruthy(); + expect(screen.queryByText("core.content.live.presence.field")).toBeNull(); + }); +}); + +describe("ContentLiveLanguagePresence", () => { + it("shows who works in a language, whichever field they are in", () => { + renderLive(, { + members: [ + member("Anna", { field: "title", locale: "pl" }), + member("Ben", { locale: "en" }), + member("Dan", { field: null, locale: "pl" }), + ], + }); + + expect(screen.getByTitle("Anna")).toBeTruthy(); + expect(screen.getByTitle("Dan")).toBeTruthy(); + expect(screen.queryByTitle("Ben")).toBeNull(); + expect( + screen.getByText("core.content.live.presence.language"), + ).toBeTruthy(); + }); + + it("narrows to one field when asked", () => { + renderLive(, { + members: [ + member("Anna", { field: "title", locale: "pl" }), + member("Dan", { field: null, locale: "pl" }), + ], + }); + + expect(screen.getByTitle("Anna")).toBeTruthy(); + expect(screen.queryByTitle("Dan")).toBeNull(); + }); + + it("renders nothing for an empty language", () => { + const { container } = renderLive( + , + { members: [member("Anna", { locale: "pl" })] }, + ); + + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/presence.tsx b/packages/vitnode/src/views/admin/views/content/live/presence.tsx new file mode 100644 index 0000000000..f97266de23 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/presence.tsx @@ -0,0 +1,228 @@ +import { cn } from "cn"; +import React from "react"; +import { useLocale, useTranslations } from "use-intl"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentLiveMember } from "@/content/live/protocol"; + +import { Avatar } from "@/components/avatar"; +import { + AvatarGroup, + AvatarGroupCount, + AvatarGroupItem, +} from "@/components/ui/avatar"; + +import { useContentLive } from "./context"; +import { + contentLiveFieldEditors, + contentLiveLanguageEditors, + contentLiveMemberColor, + contentLiveOthers, +} from "./presence-model"; + +const avatarUser = (member: ContentLiveMember) => ({ + avatarColor: contentLiveMemberColor(member.avatarColor).slice(1), + name: member.name, + nameCode: member.nameCode ?? member.name, +}); + +/** "Anna, Ben and Cleo", in the reader's language. */ +export const useContentLiveNames = () => { + const locale = useLocale(); + + return (members: readonly ContentLiveMember[]): string => + new Intl.ListFormat(locale, { type: "conjunction" }).format( + members.map(member => member.name), + ); +}; + +/** "editing Title (PL)": where one member is, from what presence says. */ +const useActivity = () => { + const t = useTranslations("core.content.live.presence"); + + return (member: ContentLiveMember, form: ContentFormSpec): string => { + const language = member.locale?.toUpperCase(); + if (member.field === null) { + return language && form.defaultLocale !== null + ? t("in_language", { language }) + : t("viewing"); + } + + const spec = form.fields.find(field => field.name === member.field); + const field = spec?.label ?? member.field; + + return spec?.localized === true && language + ? t("editing_field", { field, language }) + : t("editing_shared", { field }); + }; +}; + +/** + * Everyone else in the record: one avatar per person, however many tabs they + * have open, with where they are ("Anna, editing Title (PL)") on hover, on + * focus and for screen readers. Renders nothing outside a live form, or when + * the editor is alone. + */ +export const ContentLivePresence = ({ + className, + max = 5, +}: { + className?: string; + /** Avatars shown before the rest fold into "+N". */ + max?: number; +}) => { + const t = useTranslations("core.content.live.presence"); + const live = useContentLive(); + const names = useContentLiveNames(); + const activity = useActivity(); + if (!live?.session.live) return null; + + const others = contentLiveOthers(live.session.members, live.session); + if (others.length === 0) return null; + + const shown = others.slice(0, max); + const hidden = others.slice(max); + + return ( + + {shown.map(member => { + const where = activity(member, live.spec); + + return ( + + {member.name}, {where} + + } + > + + {where} + + ); + })} + {hidden.length > 0 ? ( + + + +{hidden.length} + + {t("more", { count: hidden.length, names: names(hidden) })} + + + + ) : null} + + ); +}; + +const SmallAvatars = ({ + members, + max = 3, +}: { + max?: number; + members: readonly ContentLiveMember[]; +}) => ( + + {members.slice(0, max).map(member => ( + + ))} + {members.length > max ? ( + + +{members.length - max} + + ) : null} + +); + +/** + * The other people in one field, as small avatars - for a field's label. A + * localized field counts only the people in `locale`. Renders nothing outside + * a live form or when the field is free. + */ +export const ContentLiveFieldPresence = ({ + className, + decorative = false, + field, + locale, +}: { + className?: string; + /** The avatars only: the field announces its editors some other way. */ + decorative?: boolean; + field: string; + /** The language shown, `null` for a field that is not localized. */ + locale: null | string; +}) => { + const t = useTranslations("core.content.live.presence"); + const live = useContentLive(); + const names = useContentLiveNames(); + if (!live?.session.live) return null; + + const editors = contentLiveFieldEditors(live.session.members, live.session, { + field, + locale, + }); + if (editors.length === 0) return null; + + return ( + + + {decorative ? null : ( + + {t("field", { count: editors.length, names: names(editors) })} + + )} + + ); +}; + +/** + * The other people working in one language - of the whole record, or of one + * field when `field` is given. For a language switcher. + */ +export const ContentLiveLanguagePresence = ({ + className, + field, + locale, +}: { + className?: string; + field?: string; + locale: string; +}) => { + const t = useTranslations("core.content.live.presence"); + const live = useContentLive(); + const names = useContentLiveNames(); + if (!live?.session.live) return null; + + const editors = contentLiveLanguageEditors( + live.session.members, + live.session, + { field, locale }, + ); + if (editors.length === 0) return null; + + return ( + + + + {t("language", { count: editors.length, names: names(editors) })} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx b/packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx new file mode 100644 index 0000000000..bc8eae598e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/rich-text-field.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +import type { CollaborativeEditorProps } from "@/components/tiptap/collaboration"; + +import { EditorSkeleton } from "@/components/tiptap/editor-skeleton"; + +import { useContentLive } from "./context"; + +// Yjs, the collaboration extensions and the editor itself: only a co-edited +// rich text field downloads them. +const ContentLiveRichTextEditor = React.lazy(async () => + import("./collaborative-editor").then(module => ({ + default: module.ContentLiveRichTextEditor, + })), +); + +/** The rich text field a co-edited editor belongs to. */ +export const ContentLiveRichTextFieldContext = React.createContext< + null | string +>(null); + +/** + * What a live form puts in `EditorCollaborationContext` around a co-edited + * rich text field: the shared editor of the field in the language shown. + */ +export const ContentLiveCollaborativeEditor = ( + props: CollaborativeEditorProps, +) => { + const live = useContentLive(); + const field = React.use(ContentLiveRichTextFieldContext); + if (!live || field === null) return null; + + return ( + }> + + + ); +}; + +/** The `EditorCollaborationContext` value of a co-edited rich text field. */ +export const renderContentLiveCollaborativeEditor = ( + props: CollaborativeEditorProps, +): React.ReactNode => ; diff --git a/packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts b/packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts new file mode 100644 index 0000000000..cda1940fb1 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/rich-text.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { RichTextDocument } from "@/content/rich-text/document"; + +import { createContentRichTextRegistry } from "./rich-text"; + +const translated: RichTextDocument = { + content: [{ content: [{ text: "Cześć", type: "text" }], type: "paragraph" }], + type: "doc", +}; + +describe("createContentRichTextRegistry", () => { + it("writes through the open editor of that field and language", () => { + const registry = createContentRichTextRegistry(); + const english = { replace: vi.fn() }; + const polish = { replace: vi.fn() }; + registry.register("content", "en", english); + registry.register("content", "pl", polish); + + registry.replace("content", "pl", translated); + + expect(polish.replace).toHaveBeenCalledWith(translated); + expect(english.replace).not.toHaveBeenCalled(); + }); + + it("keeps a document for an editor that is not open yet", () => { + const registry = createContentRichTextRegistry(); + registry.replace("content", "pl", translated); + const polish = { replace: vi.fn() }; + + registry.register("content", "pl", polish); + // Only once: a remount later does not write it again. + registry.register("content", "pl", polish); + + expect(polish.replace).toHaveBeenCalledTimes(1); + expect(polish.replace).toHaveBeenCalledWith(translated); + }); + + it("forgets an editor once it closes, and tells subscribers", () => { + const registry = createContentRichTextRegistry(); + const listener = vi.fn(); + registry.subscribe(listener); + const editor = { replace: vi.fn() }; + + const close = registry.register("content", null, editor); + expect(registry.get("content", null)).toBe(editor); + + close(); + expect(registry.get("content", null)).toBeNull(); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/live/rich-text.ts b/packages/vitnode/src/views/admin/views/content/live/rich-text.ts new file mode 100644 index 0000000000..cdd765c4b1 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/live/rich-text.ts @@ -0,0 +1,125 @@ +import React from "react"; + +import type { RichTextDocument } from "@/content/rich-text/document"; + +import { useContentLive } from "./context"; + +/** One co-edited rich text editor, as code outside it may drive it. */ +export interface ContentRichTextEditorHandle { + /** + * Replaces the whole document in one transaction. It goes through the + * shared document, so every open editor of the field gets it. + */ + replace: (document: RichTextDocument) => void; +} + +/** + * The co-edited rich text editors of one form, by field and language, so a + * layout (AI tools, a translation panel) can write through them rather than + * around them. + */ +export interface ContentRichTextRegistry { + get: ( + field: string, + locale: null | string, + ) => ContentRichTextEditorHandle | null; + register: ( + field: string, + locale: null | string, + handle: ContentRichTextEditorHandle, + ) => () => void; + /** + * Replaces a field's document now, or as soon as its editor opens - the + * language may not be on screen yet. + */ + replace: ( + field: string, + locale: null | string, + document: RichTextDocument, + ) => void; + subscribe: (listener: () => void) => () => void; +} + +const keyOf = (field: string, locale: null | string): string => + `${field}\u0000${locale ?? ""}`; + +export const createContentRichTextRegistry = (): ContentRichTextRegistry => { + const editors = new Map(); + const pending = new Map(); + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + + return { + get: (field, locale) => editors.get(keyOf(field, locale)) ?? null, + register: (field, locale, handle) => { + const key = keyOf(field, locale); + editors.set(key, handle); + const waiting = pending.get(key); + if (waiting) { + pending.delete(key); + handle.replace(waiting); + } + notify(); + + return () => { + if (editors.get(key) !== handle) return; + editors.delete(key); + notify(); + }; + }, + replace: (field, locale, document) => { + const key = keyOf(field, locale); + const editor = editors.get(key); + if (editor) { + pending.delete(key); + editor.replace(document); + + return; + } + pending.set(key, document); + }, + subscribe: listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +const subscribeNever = () => () => {}; +const getNull = () => null; + +/** + * The co-edited editor of one rich text field in one language, while it is + * open; `null` otherwise, and always outside a live form. + */ +export const useContentRichTextEditor = ( + field: string, + locale: null | string, +): ContentRichTextEditorHandle | null => { + const live = useContentLive(); + const registry = live?.richText; + + return React.useSyncExternalStore( + registry?.subscribe ?? subscribeNever, + registry ? () => registry.get(field, locale) : getNull, + getNull, + ); +}; + +/** + * Writes a whole rich text document through the co-edited editor of that + * field and language, so it reaches everyone in it. `null` when the form is + * not co-edited - the caller then sets the form value as usual. + */ +export const useContentRichTextReplace = (): + | ((field: string, locale: null | string, document: RichTextDocument) => void) + | null => { + const live = useContentLive(); + + return live?.coEditing ? live.richText.replace : null; +}; diff --git a/packages/vitnode/src/views/admin/views/content/live/root.tsx b/packages/vitnode/src/views/admin/views/content/live/root.tsx index 857e78ce91..604b8e382e 100644 --- a/packages/vitnode/src/views/admin/views/content/live/root.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/root.tsx @@ -17,6 +17,7 @@ import type { ContentLiveResetReason } from "./use-session"; import { useContentFormNavigation } from "../form/navigation"; import { useContentFormTransport } from "../form/transport"; import { ContentLiveContext } from "./context"; +import { createContentRichTextRegistry } from "./rich-text"; import { useContentLiveSession } from "./use-session"; import { contentDraftDiffers, @@ -87,6 +88,12 @@ export const ContentLiveRoot = ({ }); const { onDraft, onReset, readDrafts } = session; + // Once co-edited, always co-edited for this form: switching editors when the + // socket blinks would drop what the shared document holds. + const [coEditing, setCoEditing] = React.useState(false); + if (session.live && !coEditing) setCoEditing(true); + const [richText] = React.useState(createContentRichTextRegistry); + /** The draft the form opened on: read once, and again after a reset. */ const [opening, setOpening] = React.useState< undefined | { drafts: ContentDrafts | null; labels: Record } @@ -277,8 +284,10 @@ export const ContentLiveRoot = ({ const value: ContentLiveContextValue = { autosave: { flush, queue }, + coEditing, itemId, locale: spec.defaultLocale, + richText, session, spec, status: { ...status, dirty: contentDraftDiffers(row, rows, drafts) }, diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx index 53bce9f8a7..0c38a65003 100644 --- a/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx @@ -14,7 +14,7 @@ import { import { VitNodeWebSocketProvider } from "@/ws/provider"; import type { ContentFormTransport } from "../form/transport"; -import type { ContentLiveDraftEvent } from "./use-session"; +import type { ContentLiveDraftEvent, ContentLiveSession } from "./use-session"; import { ContentFormTransportProvider } from "../form/transport"; import { useContentLiveSession } from "./use-session"; @@ -73,10 +73,16 @@ const joinsOn = (socket: FakeWebSocket | undefined) => const Probe = ({ onDraft, + onSession, }: { onDraft?: (event: ContentLiveDraftEvent) => void; + onSession?: (session: ContentLiveSession) => void; }) => { const session = useContentLiveSession({ ...ROOM, locale: "en" }); + React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- hands the hook's API to the test + onSession?.(session); + }); React.useEffect(() => { if (!onDraft) return; @@ -116,13 +122,16 @@ const transportOf = () => { }; }; -const mount = (onDraft?: (event: ContentLiveDraftEvent) => void) => { +const mount = ( + onDraft?: (event: ContentLiveDraftEvent) => void, + onSession?: (session: ContentLiveSession) => void, +) => { const fake = transportOf(); const view = render( - + , ); @@ -285,6 +294,100 @@ describe("useContentLiveSession", () => { type: "leave", }); }); + + it("takes its seat again when the server says it lost it", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId ?? ""; + act(() => + sockets[0]?.push({ + clientId, + members: [member(clientId, "Anna")], + room: ROOM, + type: "joined", + }), + ); + + act(() => { + sockets[0]?.push({ + clientId, + code: "NOT_JOINED", + room: ROOM, + type: "error", + }); + // A second refusal while the join is on its way asks for nothing more. + sockets[0]?.push({ + clientId, + code: "NOT_JOINED", + room: ROOM, + type: "error", + }); + }); + + expect(joinsOn(sockets[0])).toHaveLength(2); + expect(screen.getByTestId("live").textContent).toBe("true"); + }); + + it("keeps its seat over a document's error, and loses it when refused", () => { + mount(); + act(() => sockets[0]?.open()); + const clientId = joinsOn(sockets[0])[0]?.clientId ?? ""; + act(() => + sockets[0]?.push({ + clientId, + members: [member(clientId, "Anna")], + room: ROOM, + type: "joined", + }), + ); + + act(() => { + sockets[0]?.push({ + clientId, + code: "NOT_FOUND", + room: ROOM, + type: "error", + }); + sockets[0]?.push({ + clientId, + code: "INVALID_MESSAGE", + type: "error", + }); + }); + expect(screen.getByTestId("live").textContent).toBe("true"); + + act(() => + sockets[0]?.push({ + clientId, + code: "FORBIDDEN", + room: ROOM, + type: "error", + }), + ); + expect(screen.getByTestId("live").textContent).toBe("false"); + }); + + it("stays in its language while it moves to a shared field", () => { + let session: ContentLiveSession | undefined; + mount(undefined, current => { + session = current; + }); + act(() => sockets[0]?.open()); + + act(() => { + session?.focus("title", "pl"); + session?.focus("categoryId", null); + session?.focus(null, null); + }); + + expect( + sockets[0]?.sent.filter(message => message.type === "focus"), + ).toEqual([ + expect.objectContaining({ field: "title", locale: "pl" }), + expect.objectContaining({ field: "categoryId", locale: "pl" }), + expect.objectContaining({ field: null, locale: "pl" }), + ]); + }); }); describe("without a socket", () => { diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.ts b/packages/vitnode/src/views/admin/views/content/live/use-session.ts index 7694c2d6b6..561f53ca45 100644 --- a/packages/vitnode/src/views/admin/views/content/live/use-session.ts +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.ts @@ -116,6 +116,11 @@ export const useContentLiveSession = ({ field: null | string; locale: null | string; }>(null); + /** The language this tab works in: the last one a field was focused in. */ + const languageRef = React.useRef(locale); + /** A join is on its way and its reply has not arrived. */ + const joiningRef = React.useRef(false); + const joinedRef = React.useRef(false); const emitDraft = React.useCallback((event: ContentLiveDraftEvent) => { seenDraftsRef.current.set(draftKey(event.locale), event.updatedAt); @@ -175,8 +180,27 @@ export const useContentLiveSession = ({ return; case "error": if (message.clientId !== clientId) return; + if (message.room && !sameContentLiveRoom(message.room, room)) return; + + if (message.code === "NOT_JOINED") { + // The server lost this tab's seat (a missed heartbeat, an instance + // restart) or a document it had open: take the seat again. Its + // `joined` reply reopens the documents. + if (!joiningRef.current && readyStateRef.current === 1) { + joiningRef.current = true; + send({ clientId, locale: languageRef.current, room, type: "join" }); + } + + return; + } + // A rich text document that is not one is the document's problem, and + // a malformed message is a bug: neither costs the seat. + if (message.code === "INVALID_MESSAGE") return; + if (message.code === "NOT_FOUND" && joinedRef.current) return; + // Refused or not there: stay on HTTP polling, which re-checks the // permission on every request anyway. + joiningRef.current = false; setJoined(false); return; @@ -187,6 +211,7 @@ export const useContentLiveSession = ({ ) { return; } + joiningRef.current = false; setJoined(true); setMembers(message.members); const me = message.members.find(member => member.clientId === clientId); @@ -229,12 +254,14 @@ export const useContentLiveSession = ({ React.useEffect(() => { if (readyState !== 1) return; - send({ clientId, locale, room, type: "join" }); + joiningRef.current = true; + send({ clientId, locale: languageRef.current, room, type: "join" }); return () => { + joiningRef.current = false; setJoined(false); }; - }, [clientId, locale, readyState, room, send]); + }, [clientId, readyState, room, send]); // Leave on unmount, on a room change, and when the page goes away. React.useEffect(() => { @@ -250,6 +277,9 @@ export const useContentLiveSession = ({ }, [clientId, room, send]); const live = joined && readyState === 1; + React.useEffect(() => { + joinedRef.current = live; + }, [live]); // Every (re)join: whatever moved while this tab was not in the room was // never delivered, and the room has forgotten where this tab was. @@ -303,10 +333,14 @@ export const useContentLiveSession = ({ const focus = React.useCallback( (field: null | string, fieldLocale: null | string) => { - focusedRef.current = { field, locale: fieldLocale }; + // A shared field (or no field) keeps the language the person is in, so + // the language switcher still shows them there. + if (fieldLocale !== null) languageRef.current = fieldLocale; + const next = { field, locale: languageRef.current }; + focusedRef.current = next; if (readyStateRef.current !== 1) return; - send({ clientId, field, locale: fieldLocale, room, type: "focus" }); + send({ clientId, room, type: "focus", ...next }); }, [clientId, room, send], ); From 50031c2843303f166860a2feec177f1bc90fca7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:36:34 +0000 Subject: [PATCH 11/25] =?UTF-8?q?feat(blog):=20=E2=9C=A8=20co-edit=20artic?= =?UTF-8?q?les=20with=20live=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Article editor header shows who else is in the article; title and slug show the people in them, the translate menu who works in each language - AI translations of the content go through the shared editor when the article is co-edited, so everyone gets them; the remount stays for solo editing - The language on screen is sent as the editor's language Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../admin/article/editor/article-editor.tsx | 24 +++++++++++++++++-- .../admin/article/editor/content-html.ts | 7 +++--- .../src/views/admin/article/editor/fields.tsx | 21 +++++++++++++--- .../views/admin/article/editor/translate.tsx | 2 ++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/plugins/blog/src/views/admin/article/editor/article-editor.tsx b/plugins/blog/src/views/admin/article/editor/article-editor.tsx index 133a60bc4e..7fc5131c07 100644 --- a/plugins/blog/src/views/admin/article/editor/article-editor.tsx +++ b/plugins/blog/src/views/admin/article/editor/article-editor.tsx @@ -10,8 +10,11 @@ import { ContentFormActions, ContentFormField, ContentFormStatusSwitch, + ContentLivePresence, useContentForm, useContentFormValues, + useContentLive, + useContentRichTextReplace, useSetContentFormValue, useTranslationFreshness, } from "@vitnode/core/content/admin-form"; @@ -81,6 +84,8 @@ export const ArticleEditor = ({ valuesRef.current = values; }); const setFormValue = useSetContentFormValue(); + const live = useContentLive(); + const replaceRichText = useContentRichTextReplace(); const { emojis } = useEditorConfig(); const ai = useArticleAi(); const [target, setTarget] = React.useState(null); @@ -117,8 +122,6 @@ export const ArticleEditor = ({ ); }; - // The editor is uncontrolled once it mounts, so a document written from - // outside it remounts it on the new value. const setContent = (locale: string, document: RichTextDocument) => { setFormValue( "content", @@ -128,6 +131,15 @@ export const ArticleEditor = ({ document, ), ); + // Co-edited, the document goes in through the shared editor - one + // transaction everyone in the article sees, and can undo. + if (replaceRichText) { + replaceRichText("content", locale, document); + + return; + } + // Alone, the editor is uncontrolled once it mounts, so a document written + // from outside it remounts it on the new value. setContentRevision(revision => revision + 1); }; @@ -282,6 +294,13 @@ export const ArticleEditor = ({ }; const fieldLocale = target ?? source; + + // The language on screen is the one this editor works in: the others see + // them there on the language menu even before a field has focus. + const focusLanguage = live?.session.focus; + React.useEffect(() => { + focusLanguage?.(null, fieldLocale); + }, [fieldLocale, focusLanguage]); const excerptPending = ai.isPending(`excerpt:${fieldLocale}`); const fieldActions: ArticleFieldActions = ai.available ? { @@ -384,6 +403,7 @@ export const ArticleEditor = ({ ) : null}

{header?.title ?? t("heading")}

+ {translationLanguages.length > 0 ? ( target ? ( { value={currentValue} /> -
+
+ {/* The field announces its editors; these are the faces. */} +
@@ -76,7 +86,7 @@ export const ArticleTitleField = ({ field }: ItemAutoFormComponentProps) => { export const ArticleSlugField = ({ field }: ItemAutoFormComponentProps) => { const t = useTranslations("@vitnode/blog.content.post.fields"); - const { currentValue, setValue } = useMultiLangField( + const { currentValue, selected, setValue } = useMultiLangField( field as MultiLangFieldProps["field"], ); @@ -99,6 +109,11 @@ export const ArticleSlugField = ({ field }: ItemAutoFormComponentProps) => { value={currentValue} /> +
diff --git a/plugins/blog/src/views/admin/article/editor/translate.tsx b/plugins/blog/src/views/admin/article/editor/translate.tsx index 6709246bdc..fc572b7b1d 100644 --- a/plugins/blog/src/views/admin/article/editor/translate.tsx +++ b/plugins/blog/src/views/admin/article/editor/translate.tsx @@ -7,6 +7,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@vitnode/core/components/ui/dropdown-menu"; +import { ContentLiveLanguagePresence } from "@vitnode/core/content/admin-form"; import { cn } from "cn"; import { ArrowRightIcon, @@ -77,6 +78,7 @@ export const TranslateMenu = ({ > {language.name} + {status === "missing" ? t("missing", { count: language.missing }) From 6fc4712add17789e7caae96c5a03cd693fb7d95a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:47:57 +0000 Subject: [PATCH 12/25] =?UTF-8?q?fix(content):=20=F0=9F=90=9B=20stop=20the?= =?UTF-8?q?=20collaborative=20editor=20from=20reopening=20its=20document?= =?UTF-8?q?=20in=20a=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor slot was an async component, so every render handed React a new promise: the field suspended, remounted and closed and reopened its shared document forever. Found by running two editors against the real socket. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../components/tiptap/collaboration.test.tsx | 26 +++++++++++++++++++ .../src/components/tiptap/collaboration.ts | 7 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/components/tiptap/collaboration.test.tsx diff --git a/packages/vitnode/src/components/tiptap/collaboration.test.tsx b/packages/vitnode/src/components/tiptap/collaboration.test.tsx new file mode 100644 index 0000000000..bd764cd735 --- /dev/null +++ b/packages/vitnode/src/components/tiptap/collaboration.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CollaborativeEditorSlot } from "./collaboration"; + +describe("CollaborativeEditorSlot", () => { + it("renders the collaborative editor in the same render, without suspending", () => { + const editor = vi.fn(({ locale }: { locale: null | string }) => ( +

Shared editor ({locale})

+ )); + + render( + {}} + render={editor} + value={null} + />, + ); + + // An async slot would hand React a new promise on every render: the field + // would suspend, remount and reopen its shared document in a loop. + expect(screen.getByText("Shared editor (pl)")).toBeTruthy(); + expect(editor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/src/components/tiptap/collaboration.ts b/packages/vitnode/src/components/tiptap/collaboration.ts index 833e66a272..b3742acaf6 100644 --- a/packages/vitnode/src/components/tiptap/collaboration.ts +++ b/packages/vitnode/src/components/tiptap/collaboration.ts @@ -28,9 +28,12 @@ export const EditorCollaborationContext = React.createContext< * component of its own, so it takes a `key` and the props a `FormControl` * adds (`id`, `aria-describedby`, `aria-invalid`). */ -export const CollaborativeEditorSlot = async ({ +export const CollaborativeEditorSlot = ({ render, ...props }: CollaborativeEditorProps & { render: (props: CollaborativeEditorProps) => React.ReactNode; -}) => render(props); +}): React.JSX.Element => + // Wrapped in an element rather than returned as is: `ReactNode` includes a + // promise, and an async component suspends on a new promise every render. + React.createElement(React.Fragment, null, render(props)); From 4fcce26ac6372693731eac5836615275638eb1b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:50:06 +0000 Subject: [PATCH 13/25] =?UTF-8?q?docs(content):=20=F0=9F=93=9D=20add=20liv?= =?UTF-8?q?e=20editing=20screenshots,=20presence=20UI=20and=20blog=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../live-editing-co-editing.png | Bin 0 -> 80802 bytes .../live-editing-field-lock.png | Bin 0 -> 52821 bytes .../docs/dev/content-engine/live-editing.mdx | 66 ++++++++++++++---- apps/web/content/docs/guides/blog.mdx | 21 +++++- 4 files changed, 69 insertions(+), 18 deletions(-) create mode 100644 apps/web/content/docs/dev/content-engine/live-editing-co-editing.png create mode 100644 apps/web/content/docs/dev/content-engine/live-editing-field-lock.png diff --git a/apps/web/content/docs/dev/content-engine/live-editing-co-editing.png b/apps/web/content/docs/dev/content-engine/live-editing-co-editing.png new file mode 100644 index 0000000000000000000000000000000000000000..832b5b21d4d1c02dbfbe80e202122d519f67eeb5 GIT binary patch literal 80802 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%0AAW)usX>Ugc_JECsODh*8-U>B} zv9WO&0WFtssSS7v($muibgQPPr=vgcczSv=f9Ub@mvN!|_Xdhx4G0LJr_X}wg#4P3 z8!TGwX?X<&nLz1}Fj}q*wsWJ8rka|4P)UNwHlhX+Z#8>W?%cTvLFuqqhK$_YDs-Qe z^lJtuEd+TX`pt^(P?AD8xL9SA1eB`z zZG8n#(T*x#(R$d9;)N?Xg;OG7qYvK7zgLz(-$H&ztH3~Dh~kXpqfGoKC6M^AU8TXt zYA=SeAUMt#Evp$?E*Y#&+S|`>XJUVgXot&##{qcI3g}h=V%N6~X$U6h24^wA(l89% zFWpb`H>ojOIH^NK~4HV-u$8UHlyJvZD>o9cmXOks9EZ&$BK< z2e=h80B6`lR{1{**1A$%){_>3b?d?PZ1YkP#=|+m%BPU2bp2ws@j>7=GO6I9OG-x z0XH)6;?m~&bS4PaXi4%xX4|JF_y;s9ME)Q!ichK~ec7K9HUg=;i-(7Y&{P#0RpN+i z*5TN(6j+16Wt&}h1k_$+LE$xh>Dp>C4o62voo=FzB}9NLF+CKYu*_+mLlqWY8y8bE zuvb!7*HhI=Ndl}cXbOT>Q(>7jmRSu+Cq)Yi1ifs%AeSqsFf=tq&zsM{a`5jqm+&g6 zV0&vzE3c^_ggEaM#nbQ8QwUuUvbOgy2MNRIc=&Pzn!!za3N0S!sO0)p6{?}f1nEc4 zwH!vqbQlc7rFP-^*8YB2v@Sc>8QsEzVPkc3d1z}wOgxUZts`1tvhM4Rd2aXXP3;nX zhy1Hu0IV-1Io6@unM5dv`f4T|zAL)MSc7)!pUpYpYoM-#?>>(l?ptt5T_DaqJCg=w zpC1?DkGIfnb**qi$jv5HTCIyPmnnWk)(J!$Ky^2a7~O#7JCUBgwenEra0;hJ@bC(ocPiHt=A0N%#_Y$O?g^5AKfcXU*d>)y}Q|g4PwJv&igeJ6ldg?9OX*{XT+;f6ucy90py6`_mIk zw{+Nl%beK9tf_IAY|u&hJ32Tx%-vrM3w(z8?w$YVD75sOqV`5+ChfIgbuD3e3@qUnX~SK04r1i~J26jjX&NIUB0y^s z{x_iDN*MG^tQd@9csO-z5I5Bh*6Oxtu%St&Lc zZG$|8VySr=RRep& zWB-1fB~Kc2!;GYb?$+ey{qa_ApWot#N!&wQ+!7jQ(*J!WPPkn|@aABetVgf1GXMTH zRc?uPzi*f43rF<+-Zvr{rTu2cmtU|XY8buREA(wbPJKV_{T`ZQ>3vCyk=6dsm7wPM z3)%uQ5BswFZZ^Kmq)#o4nBMufeu$$9JEETl9H~lz@GMna_OeYT#h8$KPAZ=LpRSRaG8^h0q0OE zc4UEdhhEIZs@D2%g#ijoIHaT8f*yb&F$WGdXfH-Gxc^iwV(M2+z9*?ZW9ODoR67)2 z$=W=%@92z4QTHQ$-$P32MujVWqEGbCKSGpnpFa=N{rv0?BvyU^^(~S^5AO{eJD|C{ z0b!GEZF2Fv+3T>~Fb&{F%kjjXD{L<|2Bqi2A`(mbTtZQjik{;{*cqtJV$+(3t zR@%3`2~58Q8gOrT972q_8+&F=)2O;ghfB!x> zNct4cZfZd*B@n2=aT!}vGQl778Ss#2x$vG7q+&ib0N2zhk`@n)0?fADSX?xK+153a zur3qxDaXOVCa<^#a`@a|RcPgiZlWEawBFJ4Q4CEg)Us?NUR=n#4u* z^z>+HX{A876mR?$Do0S=*1%1Iq5*)5lMK(vr%_Qm+uK~Q4K2({k-chuAXUdTP*uNV z68G*04nOEAOk#&9a0FNY;|ib^|JDdw9(VaH&~5(<_< zi3DNii1zjDI}C)>EYyBJDCQ?T@y~OAO^$Aa7=F(I6lDG=eNe?O0(hTn1zR3*KO(n) zMk!2?Ui9$=Eh?YMy&iepq!%xQAsO4<63P-NilgE*h-3sw%f-BiY-DyGsK`=fk7SQ! zuk$~A^yt|V1l@4B&RN zDJfUn`eYwGxaI=DG5~H=_0(fK1>bK-<34d6pP;K7+lYZYyc3b>=>5qtx7^g2IgX)^&R#b*F+nB_D2cL!p->f% zrsyVSVbAE(H+!;|*muPZFj^`RCu1lgVa@IXL@Db;WmxP-mDyLPkORr7Sep!|07k!+ zG-D2O@`E8oun8DKdKKJ%?hr{X>~`v>F+fru9l_eyAS_5IN<{<(SB2o5xG62AI{xNxi05dCRq zXD9rnmP$u>Bh}T_!zhvo(Px(zW@aoFZ*n_XUq#V#CK37RzsC6FCqLeGgY55ilZuB% zcdAcqws@Bhbl`tZ&VBJk(F%b_mbPQc0;PT(q;xGlRy+ebK$?_kJvbyBOU34FI)re# zg%M^LiNkx=S~0S0J_7q6orH%Ya18)iD|Ep&4uS3nFJkcn3d4+5tmPM{{Md}NIa_g< zOT4_ivso7nBX)`2=g^481QR9PP0^Vq=QX}UNw&-1`2lb7N978onV-d@M|{HcHvx(Z z+a7iiJ?wM*g6sJ6;~K!R01P7-Hk{;;!%I&|a8y@ZRVBhJt}v9*SLcoKy&Bvc5201| z(SAe+N39HPBhKkV^9L>oBL!)V{`sEqk|Q@l#h*A>uQ2>QIRsO|6o3<>#b(CC9`sh` zyfQth7@zWGRWhs+2`(-!etx1b>Rg7c-V@@!HQt@C#cGVPGNrKH_L-P~su#5&odQiY zfSDwN9QYF}F6$6%CIFo?9H1OW7UPUxWKl_=#|L5vQqyQmne==hL=haN)lo{6s5qXs zbTkb+UIC;-$HBoNB!n-nPD@J@#C;+bX9Z7RM)8G@9=wIl{aR&}gA92&Q+-FTySX5N z0oLOBWPMaFf8lFc5w5{%^LMHz>LL7*vdsym#Q{WRzOnrHRvy!r{#FT{B-z>KQOyQO zbeftp56bR6FdEzJZJFs2F)@`NKYF;iq1w2B!}9quJfD9zl^7f(`Tq97eRU!ibF~aH z&R+Rh^Nt`gdpKH1Bc0$~hcL-Q7{iHTV`<%m$`~J4s{t*8DB*gF;WYL)NJVD6XFC-Y zq}eYQ6%h}=L5mcJ~Y1>zBwbx4QeC-d{>tC*a-Ww3S5zs&SDbM|hPn<>sFp6>>N7M6S(yj%}^2hK>q+$3;7a;6P*g^sm1H6VHLe{FclyS zUBJ(}bQv!NJ_Ik3#aL(FY9A$Z%3~n<&wvyRz}kUZbMf><9$9lY8Xu?F-f>79Zq+Zd zw%3uyp|3QkBs8Y-SHhn&WiEd+|7F@#>B!72!H!gcapSk;+*IT$uyE9G1#mZyF>2J~ z5PZfNi!|Zz59=MeGbTn~?_-Bg;+) zDJ|`_ZaH1YuKiyEGkj`) zVkuO7xxU=I?{2=PG&k|231rZPrg9ipm&Dg(UPWhQ!8E4}qRfgbJ8?_TU8f6US-VO4 z{nfjBuef+w{Z{M8U03owk@9iQRE85vKklkJ}OO^=(|g z?LWXg@{oxDUg_k45-vFolA}9GqmktaRVj>r5>JYB7;RjzXMn5#s1Rm^#Lup+#hiClR93pf9tCH? z%IqxDK$OolAE6ZMYsk&(SB)j|f;t2WiFv3K-E5&K_yB-s?Gn~Y>)lI!Po6}PaVD^9 z(8ZTOjgE#!1!304Cnn^1E#UBgtvcfAQ!RE#Q+)%a1Y9%a!exOe$Qub=s4qC=J6M9& zt3)CGXNs`B_U+q&>CoVZc5s<<&ooMYf!yNwl>GBGMJg$RH@5C`9mGdE8@1hD6<}HV zIWjUbHFXYnCS)jey{=5@fH)Ya(U9SkhI;ahXprCIM{*d=e&n6UtqJV*!vR$n&`}fY8}+4v@phwGHhe^Zo7r zumFI{h=b|o*)0noh3y5mzWSUwoCiMu)dWm@q}a@Cjs%j8@ts)@BG7~Sp)*2gTR@lr z^T#e11g=mNpC}GFXa?Wnlc>JnV058g3p!QvNg_RtEDB8FSSAU$8P$ODP7au0J5tMTQv7^+Lj! z2*A$}K4J5kchAz$s=Uc}dk#A|5TWpoI>^we zAe2zrAR7CZ43;a>$)=}AFCM{92;CjM%mWDtf5(aRY;PDel2^$L#8S)?sF!b*<4V2@ zhU52gIT{J;!G3XTCB$`+1uLn+5KRjZW|UaVB?WpsC+)k?b)ls@8@m23H?~4twqB^@ z9zQ^4P{4Bvt(BbxnD<&4^%YN1z9CLno}L~aXTV|~@2_zWV6;KSD+FUQ^G`&x#^-?T zgi-TT5q2UN>HT5G*DtA_7#&}~e%-_@ zV-SsI-3c8NOoqVs@wT|i0L9WShxo@kd^6xJRUGdF${%{~u+UIx0t=&eP*Lpy;Sc)& zdOJz{2dL>L3q$%bi&uf_{;m@KVsCRZu$Qw8UG|dMB{Vd^8F&RTg*lCQ4&Hx)2I=-6 zndy3XOJqnp;N_xx3bDB46m3Wz2yuwcZ6_O>KBSR;3AgWtUEc-8h7PC4KW2Xfui*6@ zz??tu!rng!~HCvsWeIK-?F7BQAtB--!3w>o9z2E|5DP*9mwb+Bl& zdtPY}h+~LtnAq6h-E3%1B>k?;F$(=ab7!aW935-|#MIO$KOBnf-^jY(mlYH=1}zm< zCrjCpgjZ2fkqqg*h-k)ed1+$Cu*H$WpHPiBPl04<8P+=%pS)dWfP~j315G1%dOGCc z#*2Uz53c6>I~~les(}p{2EvPS0%?s5;h$tPe{3;NSTw?k7dRv0Dgw^W`hrTXBb?}- zChMUX#PAU10;M5qANG-hiwlB$8eZOdQY%g@6(3>9bg#x^?dlJf=L+gf$8vJRuq9aN z_1TT)b@c))3sbRfA3Zg+alC6sBIh638iK8;*bdDO*4LK7!Ez{XQXjMchl;y>Sz!^x zel#>Ravu>jvzxrd?^*G3n6m9QetW#ju#aE#&l5ktf$#bleI_m^`w!o+W2sL=RaE&F z&>=$Qr*Hdw*lq~L5jNY<=#o!ZfeKe`I1u}^JNP&){_xPtZJ6_uET}YeGcU0tr#{)( z>NG4J0*hI|P5Qm@QdtHt*2|#r4$*tDKLJMfm?EbmBZA#($Qx3ms<>QCn4m%MOKufQ zBO4$N8KHf9ppW6$Dw#%*7%vB`380mMdmjKYLXBfZ@D2JY%Io%dkze2@WO1B8rWwe@ zm{pNx3)*%5i$~%3iUqH#q&@*R8^Js;qz=KWPy6yEPEk#=K};W-{KecB<9_5yvv586 zuD1OUvD*Uoj*RPSDS+86yAWh1N>ZYs6+jiyng-A&1}uc9wYnL$V1r;FVjRc@o#UFM8k~hl~`Na9@+ANw-sA>3Sv1-7hXT9Q&qkbafGD zXyx84MW9J^M${5)qgXMut-Zj{m?t6Sh6^3+BH0gNXUGG?gytV({PniCb@}GNUB}&Z zxk`YICqQK~(-5U}wUjdV0hZ(uHm5RdvWellfCRdnoqD6cgXNdRw+T@NIRML(Sd1%c z{hpZ#x+iKCxWuXy1miwg=lA&G%|~%Z#3V?;7jv_l|G|zSdqG;OWRh3cF7sX@H6#@KaIu=E3xUZL`r3&;arT9da9&qTAqYydO?59H`?DK$WJaCJ*d*WJ~g~*3cOy zN)4;X-o)QAvs1{6GDhnW&DV3Y>C8&W2b>64(++~@bOIIpJtp^zAuks+PpVWUrI$+T z$~6?nE}KeJ#VsRxW0I&yBFkp0^LxV1d8qqu{g`tA^5B20W=J$K8h5YSR(1h~kzSY> zklA5TgB(EsGlK*Hs_}#IuVdGFwWg*46@y3tQ*`=8Tt%$VzB_i4MQp-TcG_j{_HsB` zl~&i*674}2vekR9CX_27PM(S$=go-MSnj%JJsg!^{hwTqdHvs*`b@YqDxJP0|o%`y`Z~E0wVc$ufElEpVAPQ$CE}#dt!#2jP|1sRDmVikG+92 zGzRC{v3H%DCLBsRM`Wnt;ti0b&G@{V!o_iY2OR}$;tJ(a(b#o7{+AyCJ_hBPVyS3l zPIu`ERHMm>X82ltf0rp|{&l)xT4hX!V!BKpzdvuLPn0?NBwjlfKIHPEJjylwe&gbj z+b?X!HY~AO39p8$bHYxw5xHvKXy}ab>q3su#VOJds@@6P#!47M)42F&{qZm&2peAr zU5H`R&Xn$9BoK97i0%%HK)1=Btfc0W4nD+#$*9l9!9pIokQm}(b>Ao`|M~iJn$c9* zz{?>`vL$w=K~-=KKn&W>$?t?cc6N>rN6B&f-|g+f>SF&v)~bi=MG1WwX$`%X9`w+W zQBke$aN6%#5!fkIyYjc@ylpsIpr&zQ`U>DXE;^wP`it-jKtx3jdV_isc!49xvIaoZ z$};N(@$|H}ukS0$Y(Rwp!X_%UGj(H}A*q0B7poCqA4X~x73hg8O{Y_-QdX!WBLX^V z5LHVS_+sRzG}nDPBhlnCXvX^C?39k^M17GX_;JhOXW{dSuZ=e<)bo5o&0=T8wx^eq z(%5T@sn2aveRR?S7MB(uyhh(O{>s<(Ac#ZBzkOgeUECd~ii>pWDmfL^(-oH5q4Kw+ zLMsp%WdIj(G^UpDse>4;H@YKg0_tk>6+p1Dq!T77EquMard8aaf;JFY?A1H?)d1)O zf|_;zfKj_BwlIYNDo$xTI$YHL38cs~@)1`s76|NqRMY8DuxD@7&q<4o(6PHJokEzB zUabtOJoz~zH>v)&p*5MiUp-7gija~kTJlYz>24GZpunfl1bhmr>d>B-1%Ai6NV~y5 zQ|W{LV5H#b3qZ_qcPYuuU*$JOQJEQtW1kMzQslkeAG> ztrzXrE9vsO-8Fy2yY&UE?7C`fIQp-A`XYb+$B)&G_H44K?=a-r;AO|O()T<13M!%i zfdMV;J~*?+os}ZUXF$uV2%QLN*auXP+pY(8%x@{cML`&F<@mAlei;}&j6b+bN!=H( z-kbgbyNih|)li|d50?n$Uk3GLW?GkcJzuLQYjHndi$Sfcu5tLE4PlrI40m$@M zLr`PO5+1`FbFlU^9PjaFw)lW9T0W#_LCptph-XUx9&+{e&x(+O8qH(lu?=*BeYqv`ld8Mr6WMfW3#7yL*NRGkyu8O_6)lk83MKVKe*d7lR?5~AeaceKp~?IBFpO~_(F%${RM`q3V|*?Lk-w6 zEK$81lkUH}i#)#!?+vxva3ZV>zcS~n#sq`9Jcm@TF`%z*rVSI>N7m3TzGtCx+rBnk&$1_da=O&Qsie*6DsyX z3H4Zl&_@&6QkS*$)e2Tq*7^m8xp*aQE1w?loj__%|FmdT5Oa> zg|OjcVKw>m7rqtsDt5%@=zRT}WiEUfQ?*tvcJx3f4aSdBb>NKv%}-L;Ull0%X)y4 z@7{8;$LTw-#{VGIXrb^TWq5eF60}83u1)wF_1^^_nShYtfjef|;honqq#D`nn8zw- za%90?Evw4}Uf$l#)ZytNaXN&b24+>xliXXW<~>_QbZ5Nma@^y$`j8M@P5g-3P8ip~QLem*mAhV0+9DKg{p9%!fXlSR!jFK2gQ`9?wEPelrq8>x0Mw)Nt3nZ0ZGp_8zhz1$~pTw$VOPlsYvGV8XcV{{BAffgul;VI~kNcLYZ2Z7N1cC$1Y>h@bZc)q+*r4S&T@&`}yN-nl1}VnVkasx0UEfC?JE*me~!AiuCOl zk(`A=7>v?0yj{`9ZcnAXVb2>X5j)45?zk4^LuTOSPl-7euxB068#*=xUUa$5P%#1{Qck<%v%8M%Ais^My9(5uaD;F^!H^k6M@L7R zJLh_ses$XQY(6YJyz2V}9b)ppmKPhrdVvZubn|(~TaS3qqSX9f>o$z^c>T5qBj8-# zQ&y-`T-@D73u>kBei10;rlbA!{6m9^_V4G1K)W)Lv zUEFOklm^+igTNWS+~AZA$TZk0#6h1SCx?y`K5y-3b@GpXpJ9psuG4NW_Mv2sT8hk&Ec z-QHfw*xK3xcayfHvX0TEwWXycKsWmCRhixjs)3~oDBgAs9`fG()o-U{5q_w2GAdXO zEEL+Gyjd(kIk5gb!__FUpg+$=$btYS>XpJ~4{KQey>921FAIqqTVW~h;1K{ETcQLs zxzvAR{t_r4mEf5F0>Ibs(G(2O^>PMG`#qp!wpmYZfPATbicvH08exBEr8zmsUb%qD zDq>iLkAu)c%LXbchWepPq87>XAj>y>}q!vfP;C7n`hFuqd z<^tNM5C&A!U>9Ldl#5MoW(-$zq#rcCFLF}eR)iH6t|94SMbL^e{|1OA9FMA{mnE)% zz5NO1GvsY!(dm4-0j`LY4(Xq^4?nq=`5t}$fj;#9xBd-Av8A?GE1TuU{npIz}2}A4-$SrLP|^R-z1p zLK8=9TaYnmlW6tADVb6X46^&TjT*)99_oQ~{FisO`<{PEbK6K@V{&s06HCayOTdb0 zO?9~0%tfUuG43`s_lE&fe?9NRXtl6FjAZX(5oVSu*?$E72}=g|4DWWE=@Pm28Nvsg6Jz`UkB&Yf^QXi<3bK!SxB5vJ&Cg+isvcA4PI09tJ zZ&=H!G4k>f$b$;pbdDcyO_~{85@?tu$;rtjb|6hXg@w;`_kG0qh5tp|cZXyB|Nlz% zUfHs@gk&Y-M%f{oY`IB=C|ma4$|$l|$lfEPtn3kr5HiZjp67XgKJ`84`d#ODUBB!6 zb3Xrm#C^ZV>-l^>))SkE{(Yzhpk;D2!(M2d931n{htxn|;saP(*Ot}iq02DoW9@Hl za)D3^;vnVKS-!kOTdB3!Cnz@wZY*ek=Xm!Z)9gt$b;Zdnd@jhvbJ2mdlvYI_WPTJ&H>tk4G>v6b8s2J zr>dy_s+>930mON*!=0&GAnpga6ycIk8H8utLIcs4X}eqBFBic+D%yn!Kv zu}TMIV30)l3z8GsRFiTDD{|CEv%eSSpgD%{YH4W1z+)NbrJwNL2&}Lk8%2Hz;;3Ucs z7sCRu;KS+qT~OBf_sg0Sov}Vd2EZqWCc57OYX}sf{2!cxkR0snva@}!#bNg-LFyt1 zf8$nar(wMufg|z!Y;CQWb3Dqq3oIsLL=(>!CDjD%5$Ka&5J2wy>{+79ubCz~L7E;| zP1g^LwCpUirZqqq>n%(9U?adwOKOUgk9i_#3dygVKx)sLajxzhf<{LSqQpZ z&}zTWu^r8i%|KM5Bs1V^h~obNpn0#k-a+V~75HJ?f9J7%oHi1lQog>9ocb2M9`xaX zHQiZ#^qbpQw}!HF(^5`{!Zx4R6PlnFmt{Lk9KU69s^c};*V$xL_j^xs)N#uz=y?s_7*fi0xqC@N0YnAK06_! zQzi#yIR_Z?q1Tx80dLNnD+Q3JZ%KY~(_aKtSK^Bom#TB%;WDHVzz_Zkf{4JSO_cX+ zyK#5i$jJkqny@U*rO|1BCsOD5cD>4qiuP6}s!??8VPHm~2@acR0=*{OL+t@@-@Jzh zSEGa1oYINa#_>3Iw!!z(~qtaMlmPH)qx0hS@roSJ01d^yWb?tXV zzd|v~G?8~s=urVVy$s9)Lc&fEzNTUG(?FPy$UDIRK!ZB5@jzC_vg@jOAhpj|31ULA z??cZ4xA|=63oR2|lq)PO=sIo8bF;HdHKbk`Vu{enZY(Xq%u)lIrh7HJ1XZQ6tdP4k zbVYZc{x*viMAl>MV`-U#iErEP;uXzmdjpKqq_vfm(7wc~wx#y#2xWK{VAX5h4f(-A zbyZ$>QBG#&3!EzeFM;9`n4kzR3R+qThglhyz!zky-&Q6T079v&t@VTv4oIMv;aSI6 zoML#b=Fj)^rVP~V?SGm!`usZ8eJ)FtWR~+ft;i4ttax~aI#n!Im|<<`$g%p z5^&#||JED@!a8s%Vq}`ctc^r=V=CX_HMte4+sl~{4i>DQmswlhm4 zuHeKGU6vGs4$sKQsKX7X%i(Hbg~Rtl$Ov>G-`(Fgg=HA)UxgS%UB6~$w31AMwcZDk zb?e`@H_?|D139%^+Tqv5AuK42@cjCdl*Y^@JpsguL;^hPnlRRZoL|l*IWMh-Rhl4K07sW;2`5q4Kb2u;%y|%?Tvc&advl+O3 zn`ZR)ap)KMRGFG%N8IbhoB>$mD8QQS@BPHd$q#RAo2Ha` zyWmP?wWk@@kVqzP5&kLW>Z!$Y%&k|f47XV=#D2a5skxjAYnxnU7)9|B$mf|#Ka%na zM|(35c0A3<2)Jcu(V)R7>;moi;Li}#cg z$Q&?Rb@sPCLhV1cvnw2=S4XNY=Y!)53llTLYxC~1S$-fPgROfseX-DP+IPjQijh|nxQwup%vTH%TVG%@@v9t!V1YuPvE{5`m|k^e;;$3?j5`}8LcL6 zQAl5&ISbygRCI}??D}h!%wjX?HDwjBGCUGX7O$Vkc&v;(gE%ABA$_$v-@A&{ui;RGT~BN(hBMD2=6L4H8m}M#OuRxGoDX4 zCR%u_9ntn)r?+denCWrtHjbM$K6HHDY`DVMLq4nJ;5Jetsd3fYT>Z~b=znzg895hH|r8_T7H zLk*!_Xd|0SBwjPkUUWieTl-M*NsL$-$ZM5)(+ryek-q$QzFL&si^&a9DaAR84%E!N zc-1zi^2$c&`PKQjmuYyum1}p8eVRMsy8>R=$fe11=x)2L>bH&zt>=`v9V!u2e*G15 zfOu4Wag9n|v1!qXdicEglI?>RdESnqF5T9RzlIFLQ&_WQ@=%EwbKI+?DC+d(`4u+|WZP*A&aw}QVO-TC!+s#~37Rwe^2Va#Z-#-%WaP{u8 zbOdt;aC|sZ*omfA>@7w}IIDvg%Ua^(g8W{7oCNXSRdgnMB0rUUjRQq72sBCGpr4Bs zx~0BxS>8T4ou)Nkk}TbgBrlWGBq*9pA7j}7wWDY;$n3UE4xeGAV)t`7in*)v0v%cU zw$9GN)eJ+g?4P>2JGIQV#LCf5uZG+eL>Xf3v$T~bUgYygd?)=_AWHLtqRk!WikLT2 zxQNu7Z%49PA?;C!bH>c2H8N(gObKn1vkpHEYvl^GfQS&?40V@ZND88RVZRjK5qg8~ zik8>UEZ*aSCWr8Id;ZGC@k4&$p^8|_agS^o38&Eyv=W_L{VZcC=-oP0Vd-ZhvYiM7Gl!DEL zDH;##m6%(D_ubbDs;&-VqLEryndX0?$&H6ff!}4%_Xt^%K}ecn!{E4tsK@V957@_~ z@qgNWrxG`cciEvBRT7HFXlxmq9{P=3;6;4H7w88i=FaL2@_kL7#bf=Q-(oOF1C$%2ciw;?ZjEBR)+C`At&Q&g!QjyT1vHV>y57|}9>n245cL2U=-1x3xBV+#Xk*DMw4Rz{0uz|`etnXMs+r#vkPZwTEH zYrQ<_Icq>B#d)XxgJm4aCP|OjgVvre7iNeq;fu>Ir4mFO3$t4eelVC+q^#uStVZGGxu-Z4No0D4r*u+0qUcCH|zRB7vsoK!wrq{ zsSf1V+OjQmclq`Z1ceu-KPEeJv6cVUSFC#hv@D*ogRFE07q$ouFOyP_2bSVyq>W!- zl0AtOph71;C-q7jdww(UqE-)?xfwA+&?1kQwVY%oqfX;V)qA`Y9hLFK7`d~@dpjM%Q&O;|i^m(t6=(%3#cKsvv~KGRaZH5vsEc;+K9e9~ z`-!Zz6Z<$AjK0NcbVjJw{CT+upognjVX-OZYHk zaA&D29#KLe?x>U~t$AUG(!A5Q>FM^1U-sEvJJq-)3K|y5myPvNG3ptUvM8Qif;NWm zj2Qz?fwwU%!GaHpDLTbF#H0AXzDS=K%JY{Li*UqjujZxwH2BYHt6~@}45Rf@8zKma zxX%336(DRpJDGzj2uAtpnm|xpUG0Qe!+NJql_G$Cp3wms0nW|~w5#i>5Q71&J*g@x z$)xs2cfJLXxwW4`0j`6*)y8D;G8s}EK#b&ds4A^Z&lVPosEmomUoCDliL+#}d^1DF zQM%;Gg-8GSBV*9a;Kyc8^eq3hPHET6jIWZWDie-pg94XYbmn0R`=RmAAQ5jG^8Lca zQ{6F7!$2`k=}wX-4X_Qap$FkmWt9z5eMdNAa z!4I^Uhg@fq#dyQzGPcm$4>yP-C8{>%=nH;w>>BQ*QGGPh-&ef0)g|a0g`ZkH68@;z z`5H2Z>xQ5j^(1}mvWENwHz+=R7j&_H(#*fO!GOSVC!WS8QJAm>tywHSqGA4|?GkY_ zWq$}XAZu5oi;oehY2!~YuDJc^XDF7>r#dXam7~jS(AvH8dSx?b3_E+_?}Oq_IZ8$T znrPegqd^eEg~UsH3_YzxewFdl81?AHUA?2A5&{4&49l0JV`<|{&7IWA|N3~)H-Zd} z@-GW>y1O+1Z`M)dj!#O{KGm&;OuOsO9?eSLW)YXtS67&g5!Cs4 z=b5d^)M?^rG}RlNlAqsqOw=BWVq0P>UG75)i=QqJvD#m+)@&}tXlldxO4ua+(RuJV ze8zh53N4QOPIKX$&+gC;Wmc~nF!LY7Kge$TVqx6cHje|ilIKa;tpG33K*h|7?ezl6 z;EeCP5jKd8O~662mKC=gcbj2>>ndw{kOLaYZ3xl8Rf`FD>OYX%?~lBM9W+x`n^FzG+D~N{)&zn8%g`RGu%lZ#K*Dr{`(X`ne$w10<@#qV>tF-Tx0LX?? z2~LLX>Cux}KZ%QvALGX}P6c)VcSIm3a4gAnfm)(-M2}Z~4ssyux*sArb1$*J@;}27 zE~qq3KY^jHP-@%7ZBhO9EK&Pyx3cE#u?w#)1RkavIS`<7%%IiT{6xq=z@cjrUYW)^ zu?qZw+x6_hMNf@tu=eWYBa%!e38muqL+uk@;)q)gqe##P6*4#808UvzymUPL2L z8^xXwG{f7aZI8*Z<84EBlcM&snSpo@+6}5661um?)TNZR5)BGde+~>X`o@?T5@#Kj zTkp<^YqNmj=|2e0BbdvT-HTum8u!BV8|0#KZiYY@1hQUpD3<%N)ACSsi0dH-8AdJ+ zZ30yjPG7t_orwKJ-IF}0;A`57hO&)fu(2!lsqxQZ{JJUi4Q=g1-uQ1r<5R0&C}(25VP07UE#YTmZH5QS0>H$#9uV5yZ3I%P-& zabcc#5z>}}8)5InmB5eVFvI}K2$Cy3EGG*0og>$NiRt-2Cve9q;~?@!9dz)hhlo`L z&1JNsV~eDaI?{Q2!vga9cT)uF|^Y^$3y(UWa$TGE$-MjgEx092PY9VJ=w&A)!zz}^sjFVTy~6- zv$Ql)-6$8#39J%uUdR`8ihAHXVz5pHy^vBMF8xeF;bj`R8=A-jag67Z&(&H7mlPk+ zu2OiAyvm|Kbm!$0Y#z}Jv53qxQai-yfhA`%AdI7T5<_!veD9a*)A%L9i<#gJD{=-@ zFl#9;&|V}S(eEa-znHUs{H0p0S!Z}K2eoV_c}4L6B!P5^)m$TITF#<ug)Qq6BE-;01zsNh=m%B7A7wA8uzw+16KQsx~dJ7Kr^~nnC zD%gdC6TllV$?7@x7Ym3VpHOdt=gq`EVBWZyRDLyJ?g>VlD_Aw$r9+M&q za&wt9in$jIws73k`N>WF>Q;TSgL+1usuXMF1BQhy-s5cfse$!B9XCt#k!+Q-z~kS& zXX?%-{1db;Znn=$Ddy+rnY2lS^BENe=Ye4XUZ(cu+u)boi5C*MG!dr`QG>#ynHP|B z#m@rQuCV#PW-u^%d=y@rt^2!VxA<3aqr#K*IZmI~XWxEYa3Am5H*fOWTx*`BblvP% zNi2H)wogy(w2;dCF#Q8@!{wiiltV=c$ICCzH*opi_N4MSGn71Hccq=+!f1B*7?2!u zRXky2StN)#%lmGW35huKlpK=<+kKFPB@8;R()IWw(iI{j)_R#7EFqI1Dv1m`B7?Jb z)-UsB#<`l0!Uai;QTi`AGpM6y3Ed^g{q{tmw~gx2`%rRVvHMAXDvK8hswEA#88&XC z8kD>O-HAN0>=&Y6mouMn=3Z=i<)20Cr*>S1CaN*SHWXJ5} z@a>zA_a1y_xr%@Kr4ACBx&vi8Grnkwwq?BU#RKV)>(25JDuxIm7x$fFyQ#@s-F;UZ z7-%=<%wO(^5&c~g=f6x> zw`cltTwI)%+A*2FNG8{v{2O$1bgb`a`?a|?`CDR)OXPfJv&f3T* zLM$qlGNkMSyA0tYMpm#s$XmJbCvamyID5<5#wJlo_Z58!w-94j7auiIVbEVbF0LZR zRK9D}H-Ss?fhvf4&pA!YQD2V4!aIslxTU>-3F!+Uas7MDGOnU}^ zxdypI8SuU=?er4PNU*U&$Da9~l`_J4lfbqVq<|-&Z(2`^O?(;E(sGV6(+F6?gBwB$G~2<+;geYN zW=J0e+`n;e>VY4LN@vVbSV#!q!c6X}t7jL#(o$P12E3Ld4v{cIAP^~ddcPOC$tmzx zme<$4fldRTR*zl_1{T(>+qc8%Lj%QJq3Ozp+63f)eEoBbe;=m%ABKiV9XUC`3Ko*? z03uM};@?8w3XX?OMk&^1)S9RBT@ZUh*8+fRhhYrZV5>FesMNfjL4+N--)w0z3>U zY!{n2odjMw&{}?m&q|qC4Hp{39I*amA5cfVC6g9!h80*=SX*g64?0XxG_)x^1ltS* zzX=hSI0IJO7Vzl_+(w`wTTpI(r@JjXSZ`&%eE|jzs7+~D6#xjo-57?-ujvLob<0Ok z^Aq+nEh14HR$c?~{y~A}^8<>^^=|*rh&I^da2?hhjNavdtHF@$$h|RMOn1hL`2|OX zcFh0S6TBFx!zhNHc!B=xdvo-}EV!Rwzg^56US=#Xj3_tL<`Iyt1U~6t_5wXXzHyyU z+bbX)<@lo-P~j}h@0mW5l%!*wz8{56>P&3iLN@u@V7N3kFgk(ZA@aQSQ7dZHW3GzU3s8FgJfl5o^;twIEn_N{%y zJ2{DQU|1f|!1sY;bx0>yhkS>8*8NHz^G6MeY^K}{>WmR9(N}z{AZ9o_JHw~sTb``Q zQ$;Q7gHyE31V`F9WylgFL+rxM0zD^l!Hf=Nds|zrkr$dC>C>+cb$1s)t^*{okfP4P z?p6@>6&qAwk;gm~Vh8mmL+7A9*i+?#uToGQh1A$jqN@<@yLaSnvYXL@(gYQ+*A;Ds zRz|2QUL3c}zqYs-7_tahEJ^&Ji~iuq$X&cfpjq{ejM_ClA;CkUbHa>=#gx0+P<_!evCeuX1CxPV|_q8jkG90or5K+(ol0!qxwp_V4lpGFa z?TchNvIl*jJ?(!j4s(K>M9Bwz-5L(~9rY9eAkE1~8>OR9c7=lqIAHnC zNAL!XF47rL_MoT?I6VAIcnvG_GxrCtMhJWy8@p7J3drX?Jy$nqw?|9v8I1?W7$P)h~&yaK6ZiMNr3WzQVr4!_056*3w+N-+)hbUHn4yrQtPrAL`NXxV?M_dOU$ifeSuYB_s@$f*JGoujq5Ff;bVb`yR-n zz@&mtSO;REY?-C&w!`u@L|W`D*Dx+ON+rdFy?PxN$8vQL?Xz{k`g>q%AjUAk9yH0y zBL7rdE)s#R42ePY+7(_)w}7RK#!d*L3E$sXkJWNzN@cxp`{F(HNm7SEb0ZBQYZ8HD z(R4JXl;&JVQ8^z$M?)!*0)A8+eDoBJ=2XlJf)cGm){WrgI~?uK1N(g)wzDcSl>!eH zPGZ1>&0|C2dX0pNW#26MjneXP4!+eG+!K^<18ShmHh0A+_66R}pqCnZhX2oZq=y(ak{Id%e|3`%Vvv`hvQ$-NKNoujUX(WjqsidsM({{1ELe#* zc6C_Kkyxz5P>{YwLe<*p|D)9N(CJegI`JGfA_0aL*e=T_EL;iq3q-4HFeN{7>j@#r zaO+T#a78@%#5;ocejl~FD8`Lt z%>^hAap$}0S$BF9&=(KzoYRyVuClRVc$qC<8tj`aGPt&rDLV@?;;(&ugtxwd#7r>b znPMI~8-4v(baZqGLg9))SAZNQFLCOBGT10(P=*i4OovdoE&*q9xgl9fA{rr#3Z_*` zq)g2_Pv*RpUKXT5le>$S7MXkGP{F-sPqz*Qc43^px6>M~KwQI`x94IQ{b`VkImXa^ zukm#6OV17*YE`!;Tz*Zt03xb^!SZARHtq>cgI4{uWxamLdtQjO15wEKNKa28$TCpc zV17G8)6hdGhC{uJv$F#K=DgcQT8m=1&!Wx-WHor<*`VKmWA6j)?h<5zcB7CD{2WiM zI`j4*m6v>)(F4LA*o>afrGj91uCyD&OSJxXBAK`&GjZkAjAMqJNdzw5%OZ*Ynl%Gsh+73$hP(SO2z+n6p%;3JsNjKP01NwiC z5_Atom;`Ex5E|x)Q+BNDn@50xy^CM_U`-)0LgC9PJ-`&r3@x&A;mV~q;GXhtgxG5V z;mh8cS@U2Ay_90}d+_mv^U@HMa7=N}a+%rf*|U8(w({=|3Pv_h!Ml=KQxBbet;v9_MMTcDuD=#l>zA_7I>Z*1$m(79kpH>15`+nr_Rq_jZxK1GBOZflNe|2{=)Q zZ%|UQ^c|2py8G4y-Gctj3N$Rx8E-&+i)55GNqW^tYm$7$>lFYps0&uY6dj~`eNw}9 z-1ULM&sl@?_xJ?ht8g%bDeHx(vJz7mt;lRZNd8|ez&`2Gt|U06-ZdX76TJ#}jOBv; zM(v-01u~xrBd#xJ!i)my=HlBWpAb$?LiCM%r5mM}3`!}lQ*v#FQE)o}r#S5lqO=Kj zmL>`wZ7w~}JFoq({UhsBe1u((^HCKVT`Q~X;NW04wNi+42?@cmB@0u0S?faW-ojDV-v6p0L7MdiT1@{hSdmn;w5xEk zK>%ZxY<4OeVQ6S5bcZDOoN)5HR5iTE-`^{QfiC#K)E}cs3^ZkgOoW>OaLl)uk;zE9No$PlBd*GY83@=k&q0Ci%^XEa~V25y>Gq& z<^h-n?%lh$H3FG~nTq{>t6u=Ehh`pXIMkGTyl)fR3xIDz#eISI3J!c|^~gfuy+U>5 z(b2?b&tO)Q@kIy2L9Oc+%QmD8WrxbD~dL74}x3m;l#}l0c{p>Mo3P&^b3TBRAJr;XhDDP?LB?^lpoYkOaTju z|G7^vexVZSAlVMOahNj$1bX8+2T*Q{NGN8(1b|tBXi~`Y8WmL{6e5gunw?7o5+VEs z==SenbG8gU%PCYNlpzRO`4DFhLZJcJy1>ySwp7%aStixae%s{Ht=lc2Q6SrWu%T%_LyZB}!{(xRIOj z4km%Y1%k+G(5=Dbzv>2i?jUDZ+`v`A;5y1o(@f z(u1#Ib#|77oc#OJ(q8|K4W=gO0ASWOt#!yqO=a-BrrOT+7N*hc&mho^sRJaz9Ut|# zKuHOR;6qExyWpxn{CY3teLd(oBw%k`5vKPn)OB9r$3Dv)re~$CE_XVBkLQ5W&Szd7wmiX z8z#&22RGK*vX-=*sRrq@J%<1c&-$I~1sq^?O{q1jRs;a8;@&S>N zk&ucl(j)or2aNfI9?NLa91wsZDk=(^Z&{o&u|WgNtP3~A4i4e)?CMg~cY{F0$>C_r zf8a8NB1cSIoSzsmxs;TY_OpPK+wZ%h?p`JrJFT#06Z!LDa-T+AeD%;!pyl9E4v{&e6bF~I-#7lb4$DJvtWyg`hL z0a;cNiHTJFm|%`&!T$p^3SJu;8iF81Ka%Y?*ol4+jTsyG*KCpu7mSk=V&e`6{8eNj zG}_oJR_E{kq$#|C2tnt^F%Vad|F^n!d=k5H1DdEIG~dJ*M3<$V)&J=c?!za70*5TE zskx)1L~4Nf_mj;9Z{RTF)iSYha)uZEr=%l)^OxW56?-G&(sR1+R>Qo+-w><(e3V5x>o&^jnPE@vV^?VFNYN- zzk@}*4x+TXtN?WhPN6frH$Li{MD*uQiJ*-+L-vX}5hTG|T3U{guUNub0XV`wgZb9$ z2&PsP5(nV~0E}v`#@E)Oq9Mh_#8A^bltKVz;*&R~8?asUiuB7+TG3z_9prM);SGkU zMyMZBZ1W&nR+y?$SJT#}P$oxxM8FCv;Rq;ZlvMquRnPTs7-T6xMTMy@!3dxg&-?Z+ zBagvdYzqnc%zt(_#PzYUv;P81FH0d5ZW1)O@j|BnzyY2hXCFyT>yFC}4+jbX5UQ*} z<$iiWG=F7SSVTmC%gdA$L+}`aoVQngAs92*dMp%5pfs387j&V;fQTh$2&d_yJ_r*L z5h0`%eqLBsUhYRBtEurI{{>{YuRd3pMP`st0Gm7ltHmiGCNBf{WZ2Y;Q8m@*_5|7v z!N?tW27?(yxxj(i0AiG+kirV=D$nfI(}OabLOWbx?HqnqwK&;4Gk z0C^z+z!-!yLPVI$vhXr=KyQ9@$7A!t@bnBc`VcYce;R^E0oCAe&IA+-@DFjkIdrFC zHJ6p{)xZu*>g!VzNmnWXwyi-v`5Yib1(s@@Hj@V+A2kfdBO7YV7q5n= z>sMES)G#>dBF5#iAZ`GslQ@c9R*3x1`!e|nzU>Aewa@TrQClrSdSh$vUd*)W7=&I= zHYrMenO@&&&;#JkR3YoIbu;NMtOeAfIQnbiugQ8KhrvLDA>;te9{w71p&G`(xpQiz zcc8RS^1n%Jq0E7{ZGV6Nb`XdjykPc5{0`cOizkzWtCmglf;|VP@PZDj-s;*~xHa?r zfRDyn!qgp?%Dcxhl*>u-{h;Zx@E98lib4cti0@VpKy$!+ompCgoy%mGuPXwmA5dX` zhGc0dshb4WN06?i!H9!cfT_H0<*iY@GxrYJ4)tm%rZr0#9wFgF2s;b-2G=z3eh2qX z^y}x@DBt8l4jgb#@e;^}HIv~`xaqcyf%glbM|kna4e@SJ^>aRD*A<3E3U!&_TxNhQ z=2aZ5X;fiNm0*uRzk=rmWpW0tkStcO{7>r~(o!48{MmDOM|YdMNVf@s=@Z0t-G5F9 z*6|slg^~n7gKAP`_hLT1XUvv?M@UmWoi-+9wVn8g@+sqv9q8lAl1bYAtc3n_0OjN% zjLX4b>yXWnp_OH3>JL)%-`|G0o+XJgg_vE17nkF@pcwC1vXP-a4K+OMe#c4!E{byNOEt`8#<58hqDH)hy+a!ur_4Jv-%>a zuiYo$fQdU?DDb0>-1P(kE<#PI*DOeNc7|NS1OlxXgPZ)MD_Ue+GAf>+rT`b`wN)Mj2!*Q3mXr=-U+&M^dET#qqHb6Q45u;Ig6qVJ!nlmT_Qhr$@ zbAD}647NS=odKXNN~xG?(#x4lt-tdsU$$Xv!jGgh)E@_E@Gew4})N!<^0SeBoihG#lGBIG#os# z!o;sg6=Z!7!DggL_1y-o*_$kwPZ|AOvsJ`-uCV5FpJ<=3CmZPH$aso87$=~7tsQ5V zaJy0C9GirIr*W)t$8)MaxTLR#sW%I?7yw1y0P*N2|;|N4ssOlZLL^<2X@ zuW*bOIR+M@;>=P!7s3~My47Y;teS>nR7HtB;$HN&rp-^llnbn}xNhm^COx0k zyKMav){oegG>8ibWTY}}!nBC)8|IWwt+@o8nKjl=3NUI&ng#bz;HV(gxgOH1C|AQ= z(1l<_DD_Z?x!AEkrk`#nR^XN(iDXq=`fEH)l{v{bWkK)5dO#G4Q)Ry0WgdwKhq50P z%7U!g{#+=>bD38GyA8+hr*Ewv0Jb_6IL(Ho+70l=Cb&ab1_wRwUFl5{WsxP~#R z>Eu*iKJvm&<@cbN(+STWhx0L%$H4n7a=b*0QpUVLUht3C1%7;Z!{9f+8(4xyaX&32}Qbu~p6Dq3?vE>iID;2n;!0qrW zThgD)Pd4BrDQMU~k?V~pyrz1xgh@eO0aa!$`Fk+E#}J0XQxtTCTl50Er3*})+InPb zsSr5=oSJi|jUQn38MIsufHkb@RiN4T!|`J7YC$`NMTQ=9c{|-J>heuI+Y49KrLd7u zF@HfOU03HPCJXb2xFa!)VeePr)YqL<@t1;0gLl zGD<2Af3L8YsGW>ODz;m5wSKBE0@DqczAyFp5)6!&12JIO&^E%Gf*Mrj4+CYz(y^eyHznDb$0+!k-iNr z#&tttJ3DaB56iT#0!C2@%iul*hK%tH4z8vfrAI+U!!E0v?DLhs1ABQhLyoy zgMP;8smP@hD>%3Wnebz@Q-zpMX`+OM-?_(ERdaczTXhj0lCbWqg3(C|^0T(SeTVai z#$M^(V5wM%^8n7@PK$W{@wA=>)6}?2^6%Xa}|$4@i_9EJPU~SACbLZ3e1`d~7t4uo|GUX_;LhmY51%XpRD+5x;p9Wt&b|QtN z?0IPmEEoLL4PDTRcV1c&!{6U&D>DR9!3ZlU4b;Me5w$z`Xg;RJ`#NTbT=x_Gb*{;I4={>mKcNPpm)X z%2++FxnD$hiNF0P7 z`jUsx4c~ULH6Q<)ggiGL=g;e%-`oyXJPX|)rn%e+~BC{O_zZG%nrptFa0N-?ue|JPuK%wS7$;;ZuA->i&^4p-LLYC z%&3!OUi{P6>^Ur&cur>Sh1FkdumADDXy7`x0$1#Dd}+3$N}57vZUXP>ruq}&d=55_ zypS1btX#5}vINjkdS0o2J%zKI#(%c3A>Qqdzs<&xGymhz$Wd^MJMyiV<+X=| z`gQ|t@stnoTWAu@9@y{>%2 zdumK&8a+wM<@(+isY6bFd?NRkAy_^&fwyG`e6p-5nzuBpAP2v=JSvs8&PRB6Xja|C zrANH*b4~*uTTqLlNh$k@yIEsHh0bYMf2zroyH_n5C)9#X=o?8+h4~6B9E0}_-6*;q z{FdZd{9X_}`}roGTASg_!}X!e@$+gbH9RBY$h!@%@9uM4Rwp_a$$BK-BXv~T^rNX0 z_r{%D1>r1pR768;DAd3Eat}pWi|dW`gBL!X=1VSBLFG>6=cdMKF-c$)H7(rSD>W$l z;kG~IyPZ)!s_nSsZr}Agk@%;Ae2$s>=({`P3?VzckB`NK9XGE=Gf`XNe*95*+t;Ll z(Pu&H)AMfPrmw}M`{#`;RtKMaI%%Q~e@#w&d&AC|=Uz8?QZ8gQUO$+4kttp)(mqv7 zNwL;*eLTjYpy0f}?GBrTu2#A0WBWnBKJ$sE`BB_&5lrHxbM=^5Qffc9U1m$jH5Kru zGK1a>cnjGYga}cMwVM_y_N`gmv~}B@IwkGlv%bdr(t&H)+R`Q-+pZ$}O{y-w87%~6 zWe3m2b}RdLcAUTCFp6qCFq?1ph$d%#B+3|d;<;yOf6vt_PM6(9Z%a&grSIMPhQt>7 zn7R^a7L^o34V!-VXvrbImP^|w%E-v1W#8vQ{@rc`; zZp&Lnm-%kSNSJ!4Xb?W*l;E{9{rHaFMs&XZSCiuVO6!3hu_&OE&13oV`0t(FiRSPA zg$d4^yVb?U!x?J#M4u)49Dkcz_1ubj_5uxCm6as;-jhvF)!r$0$(Q*P3D>0$dVN#n zY$VUz+!VtNOTy0YdzQ3~c{twi;a-|QC`W%H63}N%M@s|v*!o}~b*)i;x5t&60{B69 zj$`-b4VO<+j_X@knVHt~oZAN4sVvRxm`O+8?kl{HFx71EW zjp)}ZyySWwjN-~=UTK5hhp*alONf4L*L##9szooey>C7)%oF`8{pl#Z>BlS9jWyjY z8+>B1%+d?eEAu%uX|k=MB;}1<0qUT-XXzIrRG@UiIcn2K#T{g%nDd)b)wnDjiZx3_nK0(yUr^f#oL*(>PV+H#^25r>sxmS=j5v%FLMk> zHDmd#IHxvT!rs`)>Njx{=B+97E?`#UCK;RYTD7jqslWJet%uu9?RMqkVExXoX8F541$mm&9TtDTeX5M&?8d7Z%rhyv-S&AKFCRP!u?DdO ztosFTGSHujxGg+jcHv(Cy>TqUM8!2I+gABO2p|6z`b--7GpA2hf;!h;a$9H}mQQ`aKW(YWNZ2fkf7~c+ z!Y6{~v*{=M8W*>ogJsgx-WLISzKBqiB!mWkjG^{q8NoPBNIocf&Ydj6cZx!xo8@=D6mTp;hhyuBm^?t|so!z9MUM!syj=l-EjOqEW< z)T??Vyd@WhZnsF%*09y?ZhEXd@s?;=@}BkFIjhjz-sOoq`*kzpvNWvAy2dr9sW@DN z4{7KQy>p7wA7Y;jtR!aX%dx}_d<&yH`Q0AdB2{;MkfX7(UEdHKJ~l`b&1A`vsbzM_ zr1|Z+inI4jb?ZQUy3=FKoVkkGb$;EAnl0nU#Ylixu}a9c`}OcqRE;B(TqrD(sEap?!VR+)$p&Ol2ZlP~(fo z!{ssWyuEmYiA&W^vtw4CsF}}DdHbvrNV{CwGc-GSpp(j*=uCZm!x&2udSvscBg$Z>GA8fWl;c^O6;{)V!Pm}5 z-;(p+?$6`9O?nQ_{Qf0)WSU)26$Ye0MU zzfxHgeG3RiTnnjDvE0AYuy|}EdzAvGdwQ2}KX${NuApwqDP%F06ZCsl&)r^`?!AMJ zCx0v%{{%mEPbO@*RhZHxN~Taz?LAIRdv>Lnj5AMIQZ_M(Z(Umb`8w{VN_(kNpd6Kj z)axCGwd_m{<WXuLE8#-*mDPlWIrli05w_ zo9ce)npK{*xO*2fqHnM(TPyIFON+g^#1c<%b~I2dz>ncqpfdmw_UiTz&)ol@1)AI| zHdwOglV(=-d0TZKaYGd^3!M%fKfG!B?r7L8JWOrUfhjYhrKt|KbqV{0Mq^4${uuJ= z%+*pXXiH}QHLLILq3<(%ozalYVg=q>QawGsN2`i@oWYVhJA~n0)@27PYY+XUV+B9y z5Op6qFrv2Y1dv!RxKurLEI(ULy!}F1Yy#Q*Au%E0Xbnet%QRC__KWRXxpN7c#g#|o zmy;=W__n^x9Nsh*I+6-k<#~CM_=lG3{_y^n!9mVr**mYCKV2t%z$<8zH1O?5CcJD< zDtqrvNk5*(Q{Ps3aL#a4vv04}ZhbsNrK{_vd@wP_zhOm5^HS+U+_})74W)I|x? zET#^NimEm0exUK0mnOiNxSiS*P}iMp(d|R|XWaWQX<+}&F!ulY!+%sR-9(VI&qz;S zq^_i@+6c>8JUtt`%q1a;9%SRTh6|9I0whn;`*Vn>ryV!}91?aq17#cZ_sU!bSjJ`c z{RJ0gdHD{&_N%l_)y;q(0(#`yO&??9uOKl)S#(xVCIirN2!We53({J~`h0+pgDfHH z0XZB&%s(Z8`Tob&);Y*48dU=cdO%By58wt>!>=9)qy>Y%=?`%4#04xV>>NR*ngBN7 z{d-9*SwgM~3 zVfwdF05Lx#ve#uCp`sLWK-BmcNUV5n0Xkr%30l<8V8bsFy`uO{5ccHslNAy1O28s& z%GTThn+&0eTi@Mam(bts#^kmu$> zE$|=<0AYjJZxsQ$kbqCy2DP9ZVf_u49HLv&xm0C>0A7N?z+A|V(w~Nyt85&R>u}Km z#fnUt>VZ`0pvz_@`~Lkq^D0Fku2N7{m+j~(EOcHa3!}UOs;&f}O@N>S$-^$+C`o<4 z>t#d;_umt^+7Z$qckva0poL<4;~A`c-&^~9=L{%Dzz5|}*@C)7I7WAnGhKexu}+|_ z0lkf)3$|@GzcfI?B*CS-V4J}CQVbh!G(rb0fw(^a#Qn~~Bm|X%b`T}LwoMR$&1(R{ zHvsQrsG~3qDA{Y+K!lV$`3mYGQ2HnsBBv@>Ko4|!dU^vkNW@A8kg)zOs&pjxfwI)_ zzIE#s7^C#nL17GAnc{kiAjGH`1QaudV9fxO4ZVe^L=SkKjC~YV9*C8!hYD%cVY+x< zc6QDOKyDDfV-FBI2{JQh0N2|^LP3GSj+@N+8*~RO3N`fFSgY#liQISZ!yONEyp7W> z{+~}E@RjLh!W*Eidz|5waF59J_rG{{TR{On@jK9fl-3Clj$v!oIIn0ywqgySysIVX zG?aY$Xbl@cY3ftSGrX*-s=5MhkTbdd)|9&mbX-Q&e5%sZPU*P+>?Ghzx+wia2Iud4 z6#70jJ~~Hfwox|P&CLxrlKa`(_!XQ+z;?yN#Sf2|} zGhgbSa1Nuq?B_90fe*Z@ogZW3n2(i~A#{4MWr?E>y2FAaU}t^paQd>GLGw|($Uc-?*ajmQD4yH5Ybt07qu0 z_&+-Mx_Qg(U8UMfWG?{+yt8T+1D8br7i;^RZ|($+`Yrt`{kjEF&~_e(0A`)*z_XI< z{{Q)`8NBSkvTWc${G8AQ4vaf01Ih*Re{en(6BG;t-ihJtdT?uV&?NsCz>9H#JKQB& zwbv)F1m+UpHCfYjB0m8af&#sPptxa zRPhfm8JhxgByi^k_hOU$d%(l*pEtAfU;8~{ex6(baIq1vM&ows$b5400lV9?+&+<6 z?Sd3a2|D~W4 zPop2#=J}=;=@&09u4@Q81ig!_NDsJ;u1F>=?eUv8Ij6d|?Ar`Xl$p>(30!K|3N2}X yHJca{(qfGN>jhFnGH9xvXrGWqP?%7P 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 Qe?w)mYmY&Zo)hVR0j|BzhyVZp literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/content-engine/live-editing.mdx b/apps/web/content/docs/dev/content-engine/live-editing.mdx index b9a733305d..fbfbe402dd 100644 --- a/apps/web/content/docs/dev/content-engine/live-editing.mdx +++ b/apps/web/content/docs/dev/content-engine/live-editing.mdx @@ -4,6 +4,10 @@ description: Let several editors work on one Content Engine record at once. Auto icon: Users --- +import { ImgDocs } from '@/components/fumadocs/img' +import coEditing from './live-editing-co-editing.png' +import fieldLock from './live-editing-field-lock.png' + Live editing lets a whole team work on the same record at the same time without stepping on each other. Every editorial content type gets it automatically in the AdminCP: - **Autosave.** Changes are saved to a shared draft a moment after you stop typing. Nothing reaches the published record until someone presses **Save** or **Publish**. @@ -33,6 +37,12 @@ Everyone in the session needs the content type's `can_edit` permission. Viewing ## What editors see +### Presence + +The form header shows the avatars of everyone else in the record, up to five and then **+N**. Hover or focus an avatar to see where that person is, for example "Anna, editing Title (PL)". A small avatar next to a field's label shows who is in that field, in the language you are looking at. The language switcher marks each language someone is working in. + +One person with several tabs counts once, and you never see yourself. + ### Autosave and the shared draft About 1.5 seconds after you stop typing, your changes are saved to the record's draft. The form header shows the state: **Saving…**, **Draft saved at 14:32**, or **Unsaved changes** when the draft differs from the published record. @@ -45,13 +55,29 @@ Clicking into a plain field takes a lock on that field, in that language. The lo While Anna holds the title, everyone else sees the title read-only with **Anna is editing**, and the value updates live as she types. When she moves on, the field becomes editable again. + + A lock that is not renewed, because a laptop went to sleep or a tab crashed, expires on its own after 60 seconds. ### Co-editing rich text -Rich text fields are not locked. Everyone in the same field and language types into one shared document, and each person's cursor and selection show with their name and color. Undo only undoes your own changes. +Rich text fields are not locked. Everyone in the same field and language types into one shared document, and each person's cursor and selection show with their name in their avatar color. Undo only undoes your own changes. -The shared document is saved on the server about two seconds after the last change, so it is autosaved too. **Save** writes its current content to the record as JSON. + + +Each language has its own shared document, and switching the language swaps the editor. The first time anyone opens a field, one tab fills the shared document from the record. If the connection drops, the editor keeps what you type and sends it when the connection is back. + +The shared document is saved on the server about two seconds after the last change, so it is autosaved too, and it never goes into the draft. **Save** writes its current content, everyone's edits included, to the record as JSON. ### Resets @@ -93,24 +119,34 @@ The AdminCP calls these routes for you. They are mounted under the content type' ## Use live state in a custom form layout -A plugin that replaces the edit form [with its own layout](/docs/dev/content-engine/admincp) can read the session from `@vitnode/core/content/admin-form`: +A plugin that replaces the edit form [with its own layout](/docs/dev/content-engine/admincp) gets the same presence UI from `@vitnode/core/content/admin-form`: ```tsx title="plugins/blog/src/views/admin/article/editor/header.tsx" import { + ContentLiveFieldPresence, + ContentLivePresence, ContentLiveStatus, - useContentLive, } from "@vitnode/core/content/admin-form"; -export const ArticleHeader = () => { - const live = useContentLive(); - - return ( -
- - {live ? {live.session.members.length} : null} -
- ); -}; +export const ArticleHeader = ({ locale }: { locale: string }) => ( +
+
+

Title

+ +
+ + +
+); ``` -`useContentLive()` returns `null` outside a live form, so the same layout works for the create form. +| Export | What it renders | +| --- | --- | +| `ContentLivePresence` | Avatars of everyone else in the record. `max` sets how many show before **+N**, 5 by default. | +| `ContentLiveFieldPresence` | Avatars of the people in one `field` and `locale`. | +| `ContentLiveLanguagePresence` | Who is working in one `locale`. | +| `ContentLiveStatus` | The autosave state: **Saving…**, **Draft saved at…**, **Unsaved changes**. | +| `useContentLive()` | The session itself: members, locks and autosave status. | +| `useContentRichTextReplace()` | A function that replaces a rich text field's content through the shared editor, so everyone sees it and it can be undone. | + +All of them render nothing, or return `null`, outside a live form, so the same layout works for the create form. diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index d2921c93b6..7af252aac5 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -148,7 +148,7 @@ it. **Generate from title** builds the friendly URL from the translated title. With AI configured, the editor adds: - **Translate** on each field, and **Translate missing fields** for a whole - language. Content keeps its HTML structure. + language. Content keeps its formatting. - **Write with AI** on the excerpt, which writes it from the title and content. @@ -158,12 +158,27 @@ With AI configured, the editor adds: AI never saves for you. Read the suggestion, then press **Save changes**. +While others are editing the article, a translated body goes into the shared +editor as one change, so everyone sees it and you can undo it. + +### Write together + +Several people can work on one article at once. The header shows who else is +in it, and small avatars next to a field show who is editing it. The title, +slug, excerpt and other plain fields are edited by one person at a time; the +article body is shared, with everyone's cursor visible. Your changes are +autosaved to a draft until someone presses **Save changes**. See +[Live editing and autosave](/docs/dev/content-engine/live-editing) for how it +works and what it needs on your server. ### Upgrade an existing blog Articles have a localized **Excerpt** field. Search results use it as the -description and fall back to the article content when it is empty. Run your -migrations after you upgrade `@vitnode/blog`: +description and fall back to the article content when it is empty. The article +content is now stored as [rich text](/docs/dev/content-engine/rich-text) JSON +instead of HTML; the migration keeps the words of existing articles as one +paragraph, so re-check their formatting. Run your migrations after you upgrade +`@vitnode/blog`: From e09e916bee721255e075c1f73589641b2bdfb611 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:52:16 +0000 Subject: [PATCH 14/25] =?UTF-8?q?docs(plan):=20=F0=9F=93=9D=20mark=20live?= =?UTF-8?q?=20editing=20implemented=20and=20list=20known=20limitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- plans/content-live-editing.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/plans/content-live-editing.md b/plans/content-live-editing.md index 6fbdb26473..9d7e88cb72 100644 --- a/plans/content-live-editing.md +++ b/plans/content-live-editing.md @@ -1,6 +1,6 @@ # Content Engine: live editing, autosave and presence -Status: in progress (canary). Owner: Content Engine. +Status: implemented on canary (phases 0-5). Owner: Content Engine. ## Goals @@ -225,3 +225,27 @@ Vitest, following `AGENTS.md`: no `vi.mock` where a seam exists. Anchored comments, suggestion mode, AI as a visible collaborator, version history from Yjs snapshots, co-editing the visual page editor. + +## Known limitations + +Found while building and testing; none blocks the feature. + +- **Rate limit.** In production the API allows 80 requests a minute per IP. + One idle editor makes about 18. Autosave and lock renewals add more while + typing, so a large team behind one office IP can reach the limit. + Candidates: send autosave over the socket when it is live, or exempt + AdminCP sessions. +- **Seeding race.** If someone types into a brand-new shared document before + the chosen tab seeds it from the record, the seed is skipped. +- **Rich text and "Unsaved changes".** The status comes from the draft, which + never holds co-edited rich text. +- **AI writes to plain fields** (excerpt, alt text) are not autosaved or + broadcast, because they are not made while holding the field's lock. +- **Cross-instance edges.** An instance may persist a document just before it + hears about a reset; a user who leaves and rejoins at the same moment can + lose their locks. Leases expire within 60 s either way. +- **Translation delete** does not reset the live state of that language. +- **Data migration.** Existing blog HTML keeps its words as one paragraph. A + full HTML to JSON conversion script is still to be written before the + stable release. + From ebd9978dac4349931d8e5eb9a81b640e95b3c7a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:52:52 +0000 Subject: [PATCH 15/25] =?UTF-8?q?docs(content):=20=F0=9F=8E=A8=20format=20?= =?UTF-8?q?the=20live=20editing=20and=20rich=20text=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb --- .../docs/dev/content-engine/live-editing.mdx | 54 +++++++++---------- .../docs/dev/content-engine/rich-text.mdx | 34 ++++++------ 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/apps/web/content/docs/dev/content-engine/live-editing.mdx b/apps/web/content/docs/dev/content-engine/live-editing.mdx index fbfbe402dd..a4b419c08f 100644 --- a/apps/web/content/docs/dev/content-engine/live-editing.mdx +++ b/apps/web/content/docs/dev/content-engine/live-editing.mdx @@ -21,14 +21,14 @@ Live editing needs an [editorial content type](/docs/dev/content-engine/publicat ```ts title="plugins/blog/src/content/post.ts" export const blogPostContentType = defineContentType({ - id: "blog.post", - tableName: "blog_posts", + id: 'blog.post', + tableName: 'blog_posts', editorial: { enabled: true }, // [!code highlight] fields: { title: field.text({ localized: true, required: true }), content: field.richText({ localized: true, required: true }), }, -}); +}) ``` Open an existing record in the AdminCP and you are in a live session. A form that creates a new record is not live yet, because there is no record to share. @@ -87,20 +87,20 @@ Restoring a revision or deleting the record throws the drafts, locks and shared Presence and co-editing travel over the [WebSocket](/docs/dev/websocket) at `/api/ws`, so they need a long-lived Node server, such as the separate `apps/api` server or a [self-hosted](/docs/dev/deployments/self-hosted) install. -| Deployment | Autosave and locks | Presence | Rich text | -| --- | --- | --- | --- | -| Long-lived Node server | Live | Yes | Co-edited | -| Serverless, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Checked every 10 seconds | No | One editor at a time, locked like a plain field | +| Deployment | Autosave and locks | Presence | Rich text | +| ---------------------------------------------------------------- | ------------------------ | -------- | ----------------------------------------------- | +| Long-lived Node server | Live | Yes | Co-edited | +| Serverless, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Checked every 10 seconds | No | One editor at a time, locked like a plain field | With more than one API instance, set `REDIS_URL`. Instances share presence and shared-document updates over Redis pub/sub; without it, editors connected to different instances do not see each other. ## Where the data lives -| Table | Holds | -| --- | --- | -| `core_content_drafts` | The shared draft of plain fields, one row per record and language | -| `core_content_field_locks` | Active field locks, with their holder and expiry | -| `core_content_documents` | The Yjs state of each rich text field, per record and language | +| Table | Holds | +| -------------------------- | ----------------------------------------------------------------- | +| `core_content_drafts` | The shared draft of plain fields, one row per record and language | +| `core_content_field_locks` | Active field locks, with their holder and expiry | +| `core_content_documents` | The Yjs state of each rich text field, per record and language | The daily `content-editorial-cleanup` cron removes expired locks, and drafts and documents nobody touched for 30 days. A stale draft whose record has not changed since is kept, because it is somebody's unsaved work. @@ -108,12 +108,12 @@ The daily `content-editorial-cleanup` cron removes expired locks, and drafts and The AdminCP calls these routes for you. They are mounted under the content type's admin module, for example `/api/@vitnode/blog/admin/content/posts`, and all of them need `can_edit`. -| Method and path | Body | Result | -| --- | --- | --- | -| `GET /{id}/locks` | | `{ locks }`: unexpired locks of the record | -| `POST /{id}/locks` | `{ field, locale, action }` with `action` `acquire`, `renew` or `release` | `{ lock }`, or `409` `CONTENT_FIELD_LOCKED` with the current holder | -| `GET /{id}/draft` | | `{ shared, translations }`: the shared draft and one draft per language | -| `PUT /{id}/draft` | `{ locale, values }` | `{ updatedAt }`, or `409` `CONTENT_FIELD_NOT_LOCKED` for fields you do not hold | +| Method and path | Body | Result | +| ------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `GET /{id}/locks` | | `{ locks }`: unexpired locks of the record | +| `POST /{id}/locks` | `{ field, locale, action }` with `action` `acquire`, `renew` or `release` | `{ lock }`, or `409` `CONTENT_FIELD_LOCKED` with the current holder | +| `GET /{id}/draft` | | `{ shared, translations }`: the shared draft and one draft per language | +| `PUT /{id}/draft` | `{ locale, values }` | `{ updatedAt }`, or `409` `CONTENT_FIELD_NOT_LOCKED` for fields you do not hold | `locale` is `null` for a field that is not localized and the language code otherwise. A draft write only accepts fields you currently hold a lock on, and validates each value with the field's own rules. @@ -126,7 +126,7 @@ import { ContentLiveFieldPresence, ContentLivePresence, ContentLiveStatus, -} from "@vitnode/core/content/admin-form"; +} from '@vitnode/core/content/admin-form' export const ArticleHeader = ({ locale }: { locale: string }) => (
@@ -137,16 +137,16 @@ export const ArticleHeader = ({ locale }: { locale: string }) => (
-); +) ``` -| Export | What it renders | -| --- | --- | -| `ContentLivePresence` | Avatars of everyone else in the record. `max` sets how many show before **+N**, 5 by default. | -| `ContentLiveFieldPresence` | Avatars of the people in one `field` and `locale`. | -| `ContentLiveLanguagePresence` | Who is working in one `locale`. | -| `ContentLiveStatus` | The autosave state: **Saving…**, **Draft saved at…**, **Unsaved changes**. | -| `useContentLive()` | The session itself: members, locks and autosave status. | +| Export | What it renders | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `ContentLivePresence` | Avatars of everyone else in the record. `max` sets how many show before **+N**, 5 by default. | +| `ContentLiveFieldPresence` | Avatars of the people in one `field` and `locale`. | +| `ContentLiveLanguagePresence` | Who is working in one `locale`. | +| `ContentLiveStatus` | The autosave state: **Saving…**, **Draft saved at…**, **Unsaved changes**. | +| `useContentLive()` | The session itself: members, locks and autosave status. | | `useContentRichTextReplace()` | A function that replaces a rich text field's content through the shared editor, so everyone sees it and it can be undone. | All of them render nothing, or return `null`, outside a live form, so the same layout works for the create form. diff --git a/apps/web/content/docs/dev/content-engine/rich-text.mdx b/apps/web/content/docs/dev/content-engine/rich-text.mdx index cc930688cc..3c20793f41 100644 --- a/apps/web/content/docs/dev/content-engine/rich-text.mdx +++ b/apps/web/content/docs/dev/content-engine/rich-text.mdx @@ -11,7 +11,7 @@ icon: Pilcrow Add the field to a content type. A blog article body is the typical case: ```ts title="plugins/blog/src/content/post.ts" -import { defineContentType, field } from "@vitnode/core/content"; +import { defineContentType, field } from '@vitnode/core/content' export const blogPostContentType = defineContentType({ // id, tableName, localization, publicApi as before @@ -19,7 +19,7 @@ export const blogPostContentType = defineContentType({ title: field.text({ localized: true, required: true, maxLength: 255 }), content: field.richText({ localized: true, required: true }), // [!code highlight] }, -}); +}) ``` The value is a ProseMirror document, the same JSON Tiptap's `editor.getJSON()` returns: @@ -42,21 +42,21 @@ The AdminCP form shows the editor automatically. It is loaded lazily, so forms w Import `RichTextContent` from `@vitnode/core/components/rich-text` and pass the document. It works during SSR and in the browser: ```tsx title="plugins/blog/src/pages/post-page.tsx" -import { RichTextContent } from "@vitnode/core/components/rich-text"; -import { zodRichTextDocument } from "@vitnode/core/content"; -import { z } from "zod"; +import { RichTextContent } from '@vitnode/core/components/rich-text' +import { zodRichTextDocument } from '@vitnode/core/content' +import { z } from 'zod' const zodBlogPost = z.object({ content: zodRichTextDocument, title: z.string(), -}); +}) const BlogPostPage = ({ item }: { item: z.infer }) => (

{item.title}

-); +) ``` The output is wrapped in `
` and uses the same classes as the editor (`list-disc`, `tableWrapper`, `tiptap-panel` and so on), so the existing `@vitnode/core/styles/tiptap.css` styles it. @@ -82,7 +82,11 @@ Pass `nodes` or `marks` to change how one type renders. The keys are ProseMirror }} nodes={{ heading: ({ children, node }) => - node.attrs?.level === 2 ?

{children}

:

{children}

, + node.attrs?.level === 2 ? ( +

{children}

+ ) : ( +

{children}

+ ), }} /> ``` @@ -93,16 +97,16 @@ A custom renderer is your code, so it skips the built-in link and style allowlis Three helpers work on the server and in the browser without React. Import them from `@vitnode/core/content`: -| Helper | Returns | Use it for | -| -------------------------- | --------- | -------------------------------------------------------------------------- | -| `richTextToPlainText(doc)` | `string` | Search, excerpts, AI prompts, word counts. One line per block, no markup | -| `richTextToHtml(doc)` | `string` | Emails and feeds. Escaped, with the same allowlists as `RichTextContent` | -| `isRichTextEmpty(doc)` | `boolean` | Checking whether a document has any text or media | +| Helper | Returns | Use it for | +| -------------------------- | --------- | ------------------------------------------------------------------------ | +| `richTextToPlainText(doc)` | `string` | Search, excerpts, AI prompts, word counts. One line per block, no markup | +| `richTextToHtml(doc)` | `string` | Emails and feeds. Escaped, with the same allowlists as `RichTextContent` | +| `isRichTextEmpty(doc)` | `boolean` | Checking whether a document has any text or media | ```ts -import { richTextToPlainText } from "@vitnode/core/content"; +import { richTextToPlainText } from '@vitnode/core/content' -const words = richTextToPlainText(post.content).split(/\s+/).length; +const words = richTextToPlainText(post.content).split(/\s+/).length ``` The Content Engine uses them for you: the site search indexes the words of a rich text field, `delivery.seo.descriptionField` can name one and gets plain text, and the revision history compares documents as text. From bbb701d56fd14e979f29ffc68c55321b25bbfba6 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 10 Oct 2026 10:15:49 +0200 Subject: [PATCH 16/25] =?UTF-8?q?chore:=20=F0=9F=94=A7=20refresh=20lockfil?= =?UTF-8?q?e=20after=20rebasing=20on=20canary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- pnpm-lock.yaml | 64 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f4d035b57..f8c96e5d03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -561,12 +561,18 @@ importers: '@tiptap/extension-audio': specifier: ^3.31.4 version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4)) + '@tiptap/extension-collaboration': + specifier: ^3.31.4 + version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) + '@tiptap/extension-collaboration-caret': + specifier: ^3.31.4 + version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) '@tiptap/extension-drag-handle': specifier: ^3.31.4 - version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)) + version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) '@tiptap/extension-drag-handle-react': specifier: ^3.31.4 - version: 3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tiptap/extension-emoji': specifier: ^3.31.4 version: 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/suggestion@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(emojibase@17.0.0) @@ -600,6 +606,9 @@ importers: '@tiptap/suggestion': specifier: ^3.31.4 version: 3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4) + '@tiptap/y-tiptap': + specifier: ^3.0.9 + version: 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) ai: specifier: ^7.0.134 version: 7.0.134(zod@4.6.5) @@ -678,6 +687,12 @@ importers: use-intl: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.33) + yjs: + specifier: ^13.6.33 + version: 13.6.33 devDependencies: '@hono/zod-openapi': specifier: ^1.6.3 @@ -4505,6 +4520,13 @@ packages: peerDependencies: '@tiptap/core': 3.31.4 + '@tiptap/extension-collaboration-caret@3.31.4': + resolution: {integrity: sha512-1dHvh2VDxRcNehsfPGpahP9RmFcoJIecoSuhOUjDfCP0QPxuGql14mYbflYYm1arj7Z6SZLNt/2tL5Zh0fpyqQ==} + peerDependencies: + '@tiptap/core': 3.31.4 + '@tiptap/pm': 3.31.4 + '@tiptap/y-tiptap': ^3.0.7 + '@tiptap/extension-collaboration@3.31.4': resolution: {integrity: sha512-vIGuBU2oV1jIr0zGPdWc1qFo4NrzObktC06s8Z0zYCb59hm3lfnYzw0w4R2i/BYjjoKRgpiDVYweGAQHuLT7Vw==} peerDependencies: @@ -9151,8 +9173,8 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} - yjs@13.6.32: - resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + yjs@13.6.33: + resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} yocto-queue@0.1.0: @@ -12390,33 +12412,39 @@ snapshots: dependencies: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) - '@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32)': + '@tiptap/extension-collaboration-caret@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))': + dependencies: + '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) + '@tiptap/pm': 3.31.4 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + + '@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33)': dependencies: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) '@tiptap/pm': 3.31.4 - '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32) - yjs: 13.6.32 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + yjs: 13.6.33 '@tiptap/extension-document@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))': dependencies: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) - '@tiptap/extension-drag-handle-react@3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@tiptap/extension-drag-handle-react@3.31.4(@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.4)(@tiptap/react@3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@tiptap/extension-drag-handle': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)) + '@tiptap/extension-drag-handle': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) '@tiptap/pm': 3.31.4 '@tiptap/react': 3.31.4(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))': + '@tiptap/extension-drag-handle@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/extension-collaboration@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))': dependencies: '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) - '@tiptap/extension-collaboration': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32) + '@tiptap/extension-collaboration': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) '@tiptap/extension-node-range': 3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4) '@tiptap/pm': 3.31.4 - '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32) + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) '@tiptap/extension-dropcursor@3.31.4(@tiptap/extensions@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))': dependencies: @@ -12593,15 +12621,15 @@ snapshots: '@tiptap/core': 3.31.4(@tiptap/pm@3.31.4) '@tiptap/pm': 3.31.4 - '@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)': + '@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)': dependencies: lib0: 0.2.119 prosemirror-model: 1.25.12 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.1 prosemirror-view: 1.42.6 - y-protocols: 1.0.7(yjs@13.6.32) - yjs: 13.6.32 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 '@ts-morph/common@0.27.0': dependencies: @@ -17258,10 +17286,10 @@ snapshots: xmlchars@2.2.0: {} - y-protocols@1.0.7(yjs@13.6.32): + y-protocols@1.0.7(yjs@13.6.33): dependencies: lib0: 0.2.119 - yjs: 13.6.32 + yjs: 13.6.33 y18n@5.0.8: {} @@ -17281,7 +17309,7 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yjs@13.6.32: + yjs@13.6.33: dependencies: lib0: 0.2.119 From db87323fb2a8fd7b9aa76d60a0a9dfc621474acc Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 10 Oct 2026 10:16:47 +0200 Subject: [PATCH 17/25] =?UTF-8?q?test(content):=20=F0=9F=94=A5=20drop=20Po?= =?UTF-8?q?stgres=20integration=20tests=20whose=20helper=20canary=20remove?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../server/live-store.integration.test.ts | 177 ------------------ .../live/document-store.integration.test.ts | 163 ---------------- 2 files changed, 340 deletions(-) delete mode 100644 packages/vitnode/src/content/server/live-store.integration.test.ts delete mode 100644 packages/vitnode/src/content/server/live/document-store.integration.test.ts diff --git a/packages/vitnode/src/content/server/live-store.integration.test.ts b/packages/vitnode/src/content/server/live-store.integration.test.ts deleted file mode 100644 index 085072f4de..0000000000 --- a/packages/vitnode/src/content/server/live-store.integration.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -// @vitest-environment node -import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; - -import type { ContentLiveRoomRef } from "@/content/live/protocol"; - -import { - core_content_drafts, - core_content_field_locks, -} from "@/database/content-drafts"; -import { core_files } from "@/database/files"; -import { core_languages } from "@/database/languages"; -import { core_roles } from "@/database/roles"; -import { core_users } from "@/database/users"; -import { - createTestDatabase, - describePostgres, - type TestDatabaseHandle, -} from "@/tests/postgres"; - -import { - acquireContentFieldLock, - findContentFieldLock, - heldContentFieldLocks, - readContentDrafts, - writeContentDraft, -} from "./live-store"; - -const room: ContentLiveRoomRef = { contentTypeId: "test.guide", itemId: 1 }; -const title = { ...room, field: "title", locale: "en" }; - -describePostgres("the field lock and draft store", () => { - let database: TestDatabaseHandle; - let anna: number; - let marek: number; - - beforeAll(async () => { - database = await createTestDatabase({ - core_content_drafts, - core_content_field_locks, - core_files, - core_languages, - core_roles, - core_users, - }); - - await database.db - .insert(core_languages) - .values({ code: "en", name: "English", timezone: "UTC" }); - await database.db.insert(core_roles).values({ id: 1 }); - const users = await database.db - .insert(core_users) - .values( - ["anna", "marek"].map(name => ({ - avatarColor: "000000", - email: `${name}@example.com`, - ipAddress: "127.0.0.1", - name, - nameCode: name, - roleId: 1, - })), - ) - .returning({ id: core_users.id }); - [anna, marek] = users.map(user => user.id); - }); - - afterAll(async () => { - await database?.drop(); - }); - - beforeEach(async () => { - await database.db.delete(core_content_field_locks); - await database.db.delete(core_content_drafts); - }); - - it("lets exactly one of two racing editors take a free field", async () => { - const results = await Promise.all([ - acquireContentFieldLock(database.connect(), title, anna), - acquireContentFieldLock(database.connect(), title, marek), - ]); - - expect(results.filter(Boolean)).toHaveLength(1); - }); - - it("refuses a held lease, and hands over an expired one", async () => { - const start = new Date("2026-10-08T10:00:00Z"); - - expect(await acquireContentFieldLock(database.db, title, anna, start)).toBe( - true, - ); - expect( - await acquireContentFieldLock(database.db, title, marek, start), - ).toBe(false); - // The holder re-acquiring is a renewal, not a conflict. - expect(await acquireContentFieldLock(database.db, title, anna, start)).toBe( - true, - ); - - const later = new Date(start.getTime() + 61_000); - - expect( - await acquireContentFieldLock(database.db, title, marek, later), - ).toBe(true); - expect( - (await findContentFieldLock(database.db, title, later))?.user.id, - ).toBe(marek); - }); - - it("only counts the caller's unexpired locks in that language", async () => { - await acquireContentFieldLock(database.db, title, anna); - await acquireContentFieldLock( - database.db, - { ...title, field: "excerpt" }, - marek, - ); - - const held = await heldContentFieldLocks(database.db, { - locale: "en", - names: ["title", "excerpt"], - room, - userId: anna, - }); - - expect([...held]).toEqual(["title"]); - }); - - it("merges concurrent draft writes of different fields", async () => { - await Promise.all([ - writeContentDraft(database.connect(), { - baseVersion: 3, - locale: "en", - room, - userId: anna, - values: { title: "Hello" }, - }), - writeContentDraft(database.connect(), { - baseVersion: 3, - locale: "en", - room, - userId: marek, - values: { excerpt: "Short" }, - }), - ]); - await writeContentDraft(database.db, { - baseVersion: 3, - locale: null, - room, - userId: anna, - values: { categoryId: [1, 2] }, - }); - - const drafts = await readContentDrafts(database.db, room); - - expect(drafts.translations.en.values).toEqual({ - excerpt: "Short", - title: "Hello", - }); - expect(drafts.shared?.values).toEqual({ categoryId: [1, 2] }); - }); - - it("overwrites a field's earlier draft value", async () => { - const write = async (value: string) => - await writeContentDraft(database.db, { - baseVersion: 1, - locale: "en", - room, - userId: anna, - values: { title: value }, - }); - - await write("First"); - await write("Second"); - - expect( - (await readContentDrafts(database.db, room)).translations.en, - ).toMatchObject({ updatedBy: { id: anna }, values: { title: "Second" } }); - }); -}); diff --git a/packages/vitnode/src/content/server/live/document-store.integration.test.ts b/packages/vitnode/src/content/server/live/document-store.integration.test.ts deleted file mode 100644 index ed9aa4a232..0000000000 --- a/packages/vitnode/src/content/server/live/document-store.integration.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// @vitest-environment node -import type { Context } from "hono"; - -import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; - -import type { ContentLiveDocRef } from "@/content/live/protocol"; - -import { core_content_documents } from "@/database/content-documents"; -import { core_files } from "@/database/files"; -import { core_languages } from "@/database/languages"; -import { core_roles } from "@/database/roles"; -import { core_users } from "@/database/users"; -import { - createTestDatabase, - describePostgres, - type TestDatabaseHandle, -} from "@/tests/postgres"; - -import { - cleanupStaleContentDocuments, - CONTENT_DOCUMENT_SEED_CLAIM_MS, - createContentDocumentStore, -} from "./document-store"; - -const doc: ContentLiveDocRef = { - contentTypeId: "test.guide", - field: "content", - itemId: 1, - locale: "en", -}; -const shared: ContentLiveDocRef = { ...doc, field: "intro", locale: null }; -const otherRecord: ContentLiveDocRef = { ...doc, itemId: 2 }; - -describePostgres("the content document store", () => { - let database: TestDatabaseHandle; - let c: Context; - - beforeAll(async () => { - // The documents table references users, which reference these. - database = await createTestDatabase({ - core_content_documents, - core_files, - core_languages, - core_roles, - core_users, - }); - c = { - get: (key: string) => (key === "db" ? database.db : undefined), - } as unknown as Context; - }); - - afterAll(async () => { - await database?.drop(); - }); - - beforeEach(async () => { - await database.db.delete(core_content_documents); - }); - - it("round-trips a state, per language and for a shared field", async () => { - const store = createContentDocumentStore(c); - - await store.save(doc, new Uint8Array([1, 2, 3]), { - baseVersion: 4, - userId: null, - }); - await store.save(shared, new Uint8Array([9]), { - baseVersion: null, - userId: null, - }); - - expect(await store.load(doc)).toEqual(new Uint8Array([1, 2, 3])); - expect(await store.load(shared)).toEqual(new Uint8Array([9])); - expect(await store.load({ ...doc, locale: "pl" })).toBeNull(); - - await store.save(doc, new Uint8Array([7]), { - baseVersion: 5, - userId: null, - }); - - expect(await store.load(doc)).toEqual(new Uint8Array([7])); - }); - - it("lets exactly one of two openers claim the seed", async () => { - const first = createContentDocumentStore(c); - const second = createContentDocumentStore(c); - - const claims = await Promise.all([ - first.claimSeed(doc), - second.claimSeed(doc), - ]); - - expect(claims.filter(Boolean)).toHaveLength(1); - // A claim is not a state. - expect(await first.load(doc)).toBeNull(); - }); - - it("takes over a stale claim, but never a seeded document", async () => { - const store = createContentDocumentStore(c); - await store.claimSeed(doc); - await database.db.update(core_content_documents).set({ - updatedAt: new Date(Date.now() - CONTENT_DOCUMENT_SEED_CLAIM_MS - 1), - }); - - expect(await store.claimSeed(doc)).toBe(true); - - await store.save(doc, new Uint8Array([1]), { - baseVersion: 1, - userId: null, - }); - await database.db - .update(core_content_documents) - .set({ updatedAt: new Date(0) }); - - expect(await store.claimSeed(doc)).toBe(false); - }); - - it("releases only an unseeded claim", async () => { - const store = createContentDocumentStore(c); - await store.claimSeed(doc); - await store.save(shared, new Uint8Array([1]), { - baseVersion: 1, - userId: null, - }); - - await store.releaseSeed(doc); - await store.releaseSeed(shared); - - expect(await store.claimSeed(doc)).toBe(true); - expect(await store.load(shared)).toEqual(new Uint8Array([1])); - }); - - it("deletes one record's documents", async () => { - const store = createContentDocumentStore(c); - const meta = { baseVersion: 1, userId: null }; - await store.save(doc, new Uint8Array([1]), meta); - await store.save(shared, new Uint8Array([1]), meta); - await store.save(otherRecord, new Uint8Array([1]), meta); - - await store.deleteRecord(doc); - - expect(await store.load(doc)).toBeNull(); - expect(await store.load(shared)).toBeNull(); - expect(await store.load(otherRecord)).toEqual(new Uint8Array([1])); - }); - - it("cleans up documents untouched for 30 days", async () => { - const store = createContentDocumentStore(c); - const meta = { baseVersion: 1, userId: null }; - await store.save(doc, new Uint8Array([1]), meta); - await store.save(otherRecord, new Uint8Array([1]), meta); - const now = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000 + 1_000); - await store.save(shared, new Uint8Array([1]), meta); - await database.db - .update(core_content_documents) - .set({ updatedAt: now }) - .where(eq(core_content_documents.field, "intro")); - - expect(await cleanupStaleContentDocuments(c, { now })).toBe(2); - expect(await store.load(shared)).toEqual(new Uint8Array([1])); - }); -}); From 6042b317f6fe4d79a64a58c205024e310800eafa Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 10 Oct 2026 10:34:11 +0200 Subject: [PATCH 18/25] =?UTF-8?q?fix(content):=20=F0=9F=90=9B=20adapt=20ri?= =?UTF-8?q?ch=20text=20and=20live=20editing=20to=20canary's=20AI=20actions?= =?UTF-8?q?=20and=20node=20test=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../rich-text/rich-text-content.test.tsx | 1 + .../components/tiptap/collaboration.test.tsx | 1 + .../live/collaborative-editor.test.tsx | 1 + .../admin/views/content/live/field.test.tsx | 1 + .../views/content/live/presence.test.tsx | 1 + .../views/content/live/use-session.test.tsx | 1 + .../modules/admin/ai/ai.admin.module.test.ts | 32 ++++++++++++++++--- .../views/admin/article/editor/ai-review.tsx | 7 ++-- .../admin/article/editor/article-editor.tsx | 2 +- 9 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx index 767c0da99a..7f78eda1a2 100644 --- a/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx +++ b/packages/vitnode/src/components/rich-text/rich-text-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/collaboration.test.tsx b/packages/vitnode/src/components/tiptap/collaboration.test.tsx index bd764cd735..fb0cdfb54b 100644 --- a/packages/vitnode/src/components/tiptap/collaboration.test.tsx +++ b/packages/vitnode/src/components/tiptap/collaboration.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx b/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx index b3463157a8..c8e2aba40b 100644 --- a/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/collaborative-editor.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as Y from "yjs"; diff --git a/packages/vitnode/src/views/admin/views/content/live/field.test.tsx b/packages/vitnode/src/views/admin/views/content/live/field.test.tsx index 1fedf8bc10..396e14af83 100644 --- a/packages/vitnode/src/views/admin/views/content/live/field.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/field.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, render, screen, within } from "@testing-library/react"; import React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx b/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx index 4d96caa8e2..69bb7a8d6a 100644 --- a/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/presence.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx index 0c38a65003..7fccae640e 100644 --- a/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/use-session.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts index 163c29b31c..541170dbd3 100644 --- a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts +++ b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts @@ -64,6 +64,11 @@ const grant = async (cache: CacheModel, canEdit: boolean) => { ); }; +const bodyDocument = { + content: [{ content: [{ text: "Body", type: "text" }], type: "paragraph" }], + type: "doc", +}; + const modelAnswering = (text: string) => new MockLanguageModelV4({ doGenerate: { @@ -297,9 +302,26 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/translate", { - format: "html", + document: { + content: [ + { + content: [ + { text: "Hello ", type: "text" }, + { + marks: [ + { attrs: { href: "https://vitnode.com" }, type: "link" }, + ], + text: "link", + type: "text", + }, + ], + type: "paragraph", + }, + ], + type: "doc", + }, + format: "richText", from: "en", - text: '

Hello link

', to: "pl", }); @@ -315,7 +337,7 @@ describe("blog AI admin routes", () => { }); const response = await post(app, "/excerpt", { - content: "

Body

", + content: bodyDocument, locale: "en", title: "Title", }); @@ -369,7 +391,7 @@ describe("blog AI admin routes", () => { app.post("/review", async c => { const result = await c.get("ai").run({ action: ARTICLE_REVIEW_AI_ACTION, - input: { content: "

Body

", locale: "en", title: "Title" }, + input: { content: bodyDocument, locale: "en", title: "Title" }, }); return c.json(result.output); @@ -388,7 +410,7 @@ describe("blog AI admin routes", () => { app.post("/review", async c => { await c.get("ai").run({ action: ARTICLE_REVIEW_AI_ACTION, - input: { content: "

Body

", locale: "en", title: "Title" }, + input: { content: bodyDocument, locale: "en", title: "Title" }, }); return c.json({}); diff --git a/plugins/blog/src/views/admin/article/editor/ai-review.tsx b/plugins/blog/src/views/admin/article/editor/ai-review.tsx index e91304579d..0223484cb3 100644 --- a/plugins/blog/src/views/admin/article/editor/ai-review.tsx +++ b/plugins/blog/src/views/admin/article/editor/ai-review.tsx @@ -1,7 +1,10 @@ +import type { RichTextDocument } from "@vitnode/core/content/rich-text"; + import { useAvailableAiActions } from "@vitnode/core/components/ai/use-available-ai-actions"; import { Badge } from "@vitnode/core/components/ui/badge"; import { Button } from "@vitnode/core/components/ui/button"; import { Spinner } from "@vitnode/core/components/ui/spinner"; +import { isRichTextEmpty } from "@vitnode/core/content/rich-text"; import { aiErrorCodeOf, requestAiAssist, @@ -71,7 +74,7 @@ export const ArticleAiReview = ({ locale, title, }: { - content: string; + content: null | RichTextDocument; excerpt: string; locale: string; title: string; @@ -111,7 +114,7 @@ export const ArticleAiReview = ({ {t("title")}