diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9676e99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,34 @@ +name: Сообщить об ошибке +description: Воспроизводимая ошибка в Asmblyr Collaborative +title: "[Bug] " +body: + - type: markdown + attributes: + value: Для уязвимостей используйте порядок из SECURITY.md. Не публикуйте токены, персональные данные и дампы. + - type: input + id: version + attributes: + label: Версия или commit + description: Укажите commit установки или ветку, на которой воспроизводится ошибка. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Шаги воспроизведения + description: Укажите настройку, входные данные на вымышленных примерах и шаги. + validations: + required: true + - type: textarea + id: result + attributes: + label: Фактический и ожидаемый результат + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Диагностика + description: Сообщения об ошибках и журналы без секретов и данных пользователей. + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..3a828c5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Изменение + + + +## Проверка + + + +## Данные, доступ и документация + + diff --git a/.gitignore b/.gitignore index fd31291..af115ac 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ .next-visual/ .turbo/ *.tsbuildinfo +.vitepress/ apps/ui/src/generated/plugin-ui.ts apps/ui/src/generated/plugin-ui.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ebacab..8f45c17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,8 @@ ## Подготовка Следуйте [руководству первого запуска](docs/guide/getting-started.md). +Для карты компонентов и команд используйте [локальную разработку](docs/development/local-development.md), +для нового плагина — [первое расширение](docs/development/first-extension.md). Публичные пакеты SDK и Kit пока используются как workspace-пакеты репозитория. Описание компонентов — в [архитектуре](docs/development/architecture.md). diff --git a/README.md b/README.md index 4fa0c73..0787359 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,69 @@ # Asmblyr Collaborative -**Открытая платформа для работы с данными и совместного редактирования.** -Создавайте коллекции и связи в PostgreSQL, управляйте записями через админку, -настраивайте доступ команды и подключайте свои приложения через HTTP API. - -Проект развивается в статусе **ранней беты**. Интерфейс и документация — на русском. -Первая модель размещения: отдельная установка для каждой команды. - -[Документация](docs/index.md) · [Возможности](docs/guide/features.md) · -[HTTP API](docs/reference/http.md) · [Участие в проекте](CONTRIBUTING.md) - -## Возможности - -- **Модель данных:** коллекции, типы полей, связи M:1 / 1:M / M:N, - формы, подписи записей и поиск. -- **Работа с записями:** таблицы, вложенные фильтры, сохранённые виды, - редактор в боковой панели, пакетные действия и история изменений. -- **Совместная работа:** комментарии к записям, участники страниц и карточек, - проверка конфликтов при сохранении изменённых полей. -- **Доступ команды:** политики и права на действия, поля и строки, - отдельные права на настройки и ограниченное делегирование политик. -- **Авторизация:** приглашения по ссылке без обязательной почтовой доставки, - пароль, passkey, SSO/OIDC и управление сессиями. -- **Файлы:** библиотека и поля файлов с S3-совместимым хранилищем, - включая Yandex Object Storage. -- **Интеграции:** сервисные аккаунты, ключи, GitLab CI federation - и OAuth/OIDC provider для подключаемых приложений. -- **Расширения:** Kit для серверных обработчиков, собственных коллекций, - миграций, настроек, страниц и редакторов полей. -- **SDK:** генерация типов через `asm connect`, вход через админку, - fluent-запросы и типизированные методы плагинов без специального компилятора. -- **Опциональный ассистент:** потоковый чат, инструменты с правами пользователя - и OpenAI-совместимый провайдер. [Личные подключения Google Drive и Sheets](docs/features/google-workspace.md): - чтение по запросу и изменения после подтверждения пользователя. - -Границы каждой функции описаны в [карте возможностей](docs/guide/features.md). -Workspaces группируют коллекции; они не разделяют данные разных компаний. -Блокировки полей, доставка правок в реальном времени, публичный MCP endpoint -и установка расширений из интерфейса пока не поддерживаются. - -## Начать работу +**Данные команды, права доступа и расширения — на PostgreSQL.** + +Asmblyr Collaborative — открытая платформа для команд, которым нужны настраиваемые +коллекции, редактор записей и HTTP API. Разработчики могут добавить серверные +обработчики и страницы через `@asmblyr-collaborative/kit`; интеграции работают +через API, TypeScript SDK и CLI. + +**Статус: ранняя бета.** Одна установка предназначена для одной команды. +Интерфейс и основная документация сейчас на русском. SDK, CLI, Kit и контракты +доступны в npm как `0.1.0-beta.1` под тегом `beta`; выпуск не объявлен стабильным. + +[Сайт и демо](https://asmblyr.io/) · [Рабочая консоль](https://console.asmblyr.io/) · +[Быстрый старт](#быстрый-старт) · [Документация](docs/index.md) · +[Участие](CONTRIBUTING.md) + +## Что уже работает + +| Область | Возможности | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| Данные | Коллекции, типизированные поля, связи M:1 / 1:M / M:N, формы, таблицы, поиск, вложенные фильтры и сохранённые виды | +| Совместная работа | Комментарии, живое присутствие, обновление записей, блокировки полей и проверка конфликтов при сохранении | +| Доступ | Политики для действий, полей и строк; отдельные права на настройки; приглашения, пароль, passkey и SSO | +| Файлы и интеграции | S3-совместимое хранилище, сервисные аккаунты, GitLab CI federation, OAuth/OIDC provider и личные подключения Google Drive/Sheets | +| Разработка | H3-обработчики, страницы, редакторы полей, коллекции и миграции через Kit; типизированный SDK и CLI `asm connect` | +| Ассистент | Опциональный потоковый чат, инструменты с правами пользователя и действия расширений через внутренний MCP | + +Подробности и границы — в [карте возможностей](docs/guide/features.md). +Workspaces группируют коллекции, но не изолируют компании; публичного MCP endpoint +и установки расширений из интерфейса пока нет. + +## Collaborative Live + +Откройте одну запись в двух браузерах: участники видят друг друга, активное +редактирование поля и сохранённые изменения почти сразу. Таблица обновляется +через SSE; занятые поля показывают имя редактора. Если два человека изменили +одно поле, сохранение черновика возвращает конфликт и предлагает выбрать +значение. [Контракт и ограничения](docs/features/realtime.md). + +## Ассистент и расширения + +При настройке OpenAI-совместимого провайдера ассистент работает с разрешёнными +данными и явно опубликованными действиями расширений. `defineModelContext` +публикует H3-обработчик для ассистента и внутреннего MCP; обычный HTTP-обработчик +не становится инструментом автоматически. Доступ к данным дополнительно зависит +от прав вызывающего и настройки публикации коллекции в MCP. Личные подключения +Google Drive и Sheets поддерживают чтение по запросу и изменение после +подтверждения пользователя. + +[Как работает ассистент](docs/development/assistant-architecture.md) · +[Первое расширение](docs/development/first-extension.md) · +[Контракт Kit](docs/reference/kit-guide.md) + +## Быстрый старт Нужны **Node.js 22+, pnpm 11.13.1 и Docker Compose**. Из корня репозитория: ```sh pnpm install --frozen-lockfile pnpm db:up +cp -n apps/core/.env.example apps/core/.env ``` -Создайте `apps/core/.env` из [шаблона](apps/core/.env.example) и задайте случайный -`ASMBLYR_SETUP_TOKEN` длиной не менее 32 символов. Затем: +В `apps/core/.env` задайте случайный `ASMBLYR_SETUP_TOKEN` длиной не менее 32 +символов. Затем: ```sh pnpm db:migrate @@ -57,7 +71,8 @@ pnpm dev ``` Откройте [localhost:3000/setup](http://localhost:3000/setup), чтобы создать первого -администратора. Подробнее — [первый запуск](docs/guide/getting-started.md). +администратора. Копируйте шаблон `.env` только если файла ещё нет. Подробнее — +[первый запуск](docs/guide/getting-started.md). Для Docker и Kubernetes предусмотрены отдельные образы Core и UI, собранные из одного коммита, Docker Compose и Helm-чарт. PostgreSQL и S3 подключаются отдельно: [установка и настройка](deploy/README.md). CI публикует согласованную пару @@ -67,36 +82,40 @@ SDK, CLI, Kit и общие контракты доступны в npm как `0 `beta`: [установка и подключение](docs/reference/packages.md). Docker-сборка не зависит от предварительной публикации этих пакетов. -## Устройство проекта - -| Каталог | Назначение | -| ---------------------------------- | -------------------------------------------------- | -| `apps/core` | HTTP API, авторизация и PostgreSQL; Fastify и Knex | -| `apps/ui` | Админка; Next.js, React и shadcn | -| `packages/contracts` | Общие публичные типы | -| `packages/sdk` | TypeScript HTTP-клиент | -| `packages/cli` | Подключение SDK и генерация типов | -| `packages/kit` | API и сборщик расширений | -| `packages/plugin-comments` | Встроенный модуль комментариев к записям | -| `packages/plugin-google-workspace` | Опциональные инструменты Google Drive и Sheets | -| `examples` | Опциональные примеры расширений и интеграций | -| `deploy` | Универсальные шаблоны Docker-установки | -| `docs` | Пользовательские руководства и контракты | -| `.agents` | Подробные инструкции для работы агентов с кодом | - -## Развитие и участие - -Текущий фокус — устойчивость беты, совместимость SDK/Kit, документация и удобство -основных сценариев работы с данными. Новые возможности обсуждаются в -[Issues](https://github.com/Asmblyr/Collaborative/issues); сроки и состав следующих -релизов пока не закреплены. - -Нашли ошибку или хотите предложить изменение? Начните с -[руководства для участников](CONTRIBUTING.md). Оно описывает запуск проверок, -правила изменений и подготовку pull request. -О вопросах безопасности читайте в [SECURITY.md](SECURITY.md). +## Развёртывание + +Основной способ установки использует отдельные образы Core и UI. Совместимый +монолитный образ доступен для существующих установок. [Docker и обновления](docs/guide/deployment.md) +описывают сборку из исходников, Compose и эксплуатационные границы. +[Опубликованные пакеты](docs/reference/packages.md) и +[CLI](docs/reference/cli-guide.md) предназначены для внешних клиентов. + +## Как устроен проект + +```mermaid +flowchart LR + Browser[Браузер] -->|страницы| UI[Next.js UI] + Browser -->|/api| API[Fastify Core API] + SDK[TypeScript SDK и CLI] --> API + API --> DB[(PostgreSQL)] + API --> S3[(S3-совместимое хранилище)] + Kit[Расширения через Kit] --> API +``` + +Core отвечает за HTTP, авторизацию и данные; UI предоставляет админку. +Браузерная сессия обрабатывается Core. Серверный код расширений исполняется внутри Core как доверенный +код. [Архитектура](docs/development/architecture.md) и +[структура репозитория](docs/development/local-development.md) раскрывают границы +компонентов и хранения. + +## Документация и участие + +- [Руководства и справочники](docs/index.md) · [HTTP API](docs/reference/http.md) · [TypeScript SDK](docs/reference/sdk-guide.md) +- [Локальная разработка](docs/development/local-development.md) · [Участие в проекте](CONTRIBUTING.md) +- [Безопасность и сообщение об уязвимости](SECURITY.md) ## Лицензия -[MIT](LICENSE). Сторонние зависимости сохраняют свои лицензии. -Дополнительное локальное S3-хранилище использует MinIO под AGPL. +[MIT](LICENSE). Сторонние зависимости сохраняют свои лицензии. Дополнительное +локальное S3-хранилище использует MinIO под AGPL. Встроенный шрифт Geist +распространяется по [SIL OFL 1.1](apps/ui/src/app/fonts/OFL.txt). diff --git a/apps/core/migrations/20261006030000_realtime_field_locks.cjs b/apps/core/migrations/20261006030000_realtime_field_locks.cjs new file mode 100644 index 0000000..6798ff5 --- /dev/null +++ b/apps/core/migrations/20261006030000_realtime_field_locks.cjs @@ -0,0 +1,20 @@ +exports.up = async (knex) => { + await knex.schema.withSchema("public").createTable("asmblyr_field_locks", (table) => { + table.uuid("collection_id").notNullable().references("id").inTable("public.asmblyr_collections").onDelete("CASCADE"); + table.text("record_id").notNullable(); + table.text("field").notNullable(); + table.uuid("session_id").notNullable().references("id").inTable("public.asmblyr_auth_sessions").onDelete("CASCADE"); + table.uuid("client_id").notNullable(); + table.uuid("user_id").notNullable().references("id").inTable("public.asmblyr_users").onDelete("CASCADE"); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("expires_at", { useTz: true }).notNullable(); + table.primary(["collection_id", "record_id", "field"]); + table.index("expires_at"); + table.index(["session_id", "client_id"]); + }); +}; + +// Locks are transient editor leases. Rolling back discards only active leases. +exports.down = async (knex) => { + await knex.schema.withSchema("public").dropTable("asmblyr_field_locks"); +}; diff --git a/apps/core/src/app.ts b/apps/core/src/app.ts index f4a4350..67e99fe 100644 --- a/apps/core/src/app.ts +++ b/apps/core/src/app.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; import { registerReadiness } from "./health/readiness.js"; import { registerPresenceRoutes } from "./presence/routes.js"; +import { PostgresRealtimeBus, InMemoryRealtimeBus } from "./realtime/bus.js"; +import { registerRealtimeRoutes } from "./realtime/routes.js"; +import { expireLocks } from "./realtime/locks.js"; import { registerNotificationRoutes } from "./notifications/routes.js"; import { registerErrorHandler } from "./http/error-handler.js"; import Fastify from "fastify"; @@ -145,6 +148,11 @@ export function createApp({ const database = databaseUrl ? knex({ client: "pg", connection: postgresConnection(databaseUrl) }) : null; + const realtime = databaseUrl + ? new PostgresRealtimeBus(databaseUrl, (error) => + app.log.error({ err: error }, "Realtime listener failed"), + ) + : new InMemoryRealtimeBus(); if (database) { app.decorate("credentialLimit", (key: string, limit: number) => requestBucket(database, `credential-route:${key}`, 60000, limit), @@ -213,6 +221,9 @@ export function createApp({ Boolean(google && (await google.available(access.principal.id))), ); app.addHook("onReady", async () => { + if (realtime instanceof PostgresRealtimeBus) { + await realtime.start(); + } if (database) { await installPluginCollections(database, plugins); } else if (plugins.some((plugin) => plugin.collections?.length)) { @@ -242,6 +253,15 @@ export function createApp({ registerProfileExtensionRoutes(app, database, hooks.mutation); registerProfileDisplayRoutes(app, database); registerPresenceRoutes(app, database); + registerRealtimeRoutes(app, database, realtime); + const lockCleanup = database + ? setInterval(() => { + void expireLocks(database).catch((error) => + app.log.error({ err: error }, "Realtime lock cleanup failed"), + ); + }, 15_000) + : null; + lockCleanup?.unref(); registerNotificationRoutes(app, database, plugins); registerServiceRoutes(app, database); registerFederationRoutes(app, database); @@ -300,6 +320,8 @@ export function createApp({ ); app.addHook("onClose", async () => { + if (lockCleanup) clearInterval(lockCleanup); + await realtime.close(); integrationRuntime?.close(); fileStorage?.close?.(); await database?.destroy(); diff --git a/apps/core/src/http/error-handler.ts b/apps/core/src/http/error-handler.ts index 9935b70..fef3867 100644 --- a/apps/core/src/http/error-handler.ts +++ b/apps/core/src/http/error-handler.ts @@ -32,7 +32,10 @@ export function describeRequestError( const message = exposeMessage ? failure.message : "An internal error occurred"; - return { status, body: { code, message, requestId: request.id } }; + return { + status, + body: { code, message, details: {}, requestId: request.id }, + }; } export function registerErrorHandler(app: FastifyInstance): void { diff --git a/apps/core/src/items/events-repository.ts b/apps/core/src/items/events-repository.ts index 382a823..c59a492 100644 --- a/apps/core/src/items/events-repository.ts +++ b/apps/core/src/items/events-repository.ts @@ -5,6 +5,7 @@ import type { Knex } from "knex"; import type { HookEvents } from "@asmblyr-collaborative/kit"; import { findCollectionSettings } from "../collections/settings-repository.js"; import { ItemError, parseCollectionName, parseItemId } from "./validation.js"; +import { publishRealtime, realtimeEvent } from "../realtime/publish.js"; type JsonRow = Record; type EventAction = "create" | "update" | "delete"; @@ -56,7 +57,7 @@ export async function recordItemEvent( context: MutationContext, ): Promise { const redacted = await sensitiveFields(transaction, collection.name); - await transaction("asmblyr_item_events") + const [recorded] = await transaction("asmblyr_item_events") .withSchema("public") .insert({ collection_id: collection.id, @@ -70,7 +71,32 @@ export async function recordItemEvent( before === null ? null : jsonRow(redactItemValues(before, redacted)!), after: after === null ? null : jsonRow(redactItemValues(after, redacted)!), - }); + }) + .returning<{ id: string }[]>("id"); + const changed = ( + before && after + ? Object.keys(changedFields(before, after).after) + : Object.keys(before ?? after ?? {}) + ).filter((field) => !redacted.includes(field)); + const liveEvent = realtimeEvent( + action === "create" + ? "record.created" + : action === "update" + ? "record.updated" + : "record.deleted", + context.actor, + { + collection: collection.name, + recordId: collection.itemId, + changedFields: changed, + revision: String(recorded.id), + }, + ); + // NOTIFY is limited to 8 KB. Keep the invalidation when a schema is wide. + if (Buffer.byteLength(JSON.stringify(liveEvent)) > 7_500) { + liveEvent.payload.changedFields = []; + } + await publishRealtime(transaction, liveEvent); await context.onItemEvent?.(transaction, `items.${action}`, { collection: collection.name, collectionId: collection.id, diff --git a/apps/core/src/realtime/bus.ts b/apps/core/src/realtime/bus.ts new file mode 100644 index 0000000..d2636e2 --- /dev/null +++ b/apps/core/src/realtime/bus.ts @@ -0,0 +1,199 @@ +import pg from "pg"; +import type { Client } from "pg"; +import { randomUUID } from "node:crypto"; +import type { + PresenceScope, + RealtimeEvent, +} from "@asmblyr-collaborative/contracts"; + +export const realtimeChannel = "asmblyr_realtime"; +export type RealtimeListener = (event: RealtimeEvent) => void; + +export interface RealtimeBus { + publish(event: RealtimeEvent): Promise; + subscribe(scope: PresenceScope, listener: RealtimeListener): () => void; + close(): Promise; +} + +function scopeKeys(scope: PresenceScope): string[] { + const presence = `presence:${JSON.stringify(scope)}`; + if (scope.kind === "page") return [presence]; + if (scope.kind === "collection") { + return [presence, `collection:${scope.collection}`]; + } + return [presence, `record:${JSON.stringify([scope.collection, scope.id])}`]; +} + +function eventKeys(event: RealtimeEvent): string[] | null { + if (event.type === "presence.changed") { + return [`presence:${JSON.stringify(event.payload.scope)}`]; + } + if (event.type === "collection.changed") { + return event.payload.collection + ? [`collection:${event.payload.collection}`] + : null; + } + if (event.type === "field.locked" || event.type === "field.unlocked") { + return [ + `record:${JSON.stringify([event.payload.collection, event.payload.recordId])}`, + ]; + } + return [ + `collection:${event.payload.collection}`, + `record:${JSON.stringify([event.payload.collection, event.payload.recordId])}`, + ]; +} + +export class InMemoryRealtimeBus implements RealtimeBus { + private readonly listeners = new Map>(); + + async publish(event: RealtimeEvent): Promise { + const keys = eventKeys(event); + const groups = + keys === null + ? this.listeners.values() + : keys.map((key) => this.listeners.get(key)); + const recipients = new Set(); + for (const group of groups) { + for (const listener of group ?? []) { + recipients.add(listener); + } + } + for (const listener of recipients) listener(event); + } + + subscribe(scope: PresenceScope, listener: RealtimeListener): () => void { + const keys = scopeKeys(scope); + for (const key of keys) { + const group = this.listeners.get(key) ?? new Set(); + group.add(listener); + this.listeners.set(key, group); + } + return () => { + for (const key of keys) { + const group = this.listeners.get(key); + group?.delete(listener); + if (!group?.size) this.listeners.delete(key); + } + }; + } + + async close(): Promise { + this.listeners.clear(); + } +} + +/** PostgreSQL delivers notifications only after the writing transaction commits. */ +export class PostgresRealtimeBus implements RealtimeBus { + private readonly local = new InMemoryRealtimeBus(); + private client: Client | null = null; + private retry: ReturnType | null = null; + private closed = false; + private started = false; + + constructor( + private readonly databaseUrl: string, + private readonly report: (error: Error) => void, + ) {} + + async start(): Promise { + if (this.closed || this.client) { + return; + } + const client = new pg.Client({ connectionString: this.databaseUrl }); + this.client = client; + client.on("error", (error) => this.disconnected(client, error)); + client.on("end", () => this.disconnected(client)); + client.on("notification", (notification) => { + if (notification.channel !== realtimeChannel || !notification.payload) { + return; + } + try { + const event = JSON.parse(notification.payload) as RealtimeEvent; + if ( + event && + typeof event.type === "string" && + typeof event.id === "string" && + event.payload && + typeof event.payload === "object" + ) { + void this.local.publish(event).catch((error: unknown) => { + this.report(error as Error); + }); + } + } catch { + // Malformed notifications cannot interrupt other subscribers. + } + }); + try { + await client.connect(); + await client.query(`LISTEN ${realtimeChannel}`); + if (this.started) { + this.local.publish({ + id: randomUUID(), + type: "collection.changed", + timestamp: new Date().toISOString(), + workspaceId: null, + actor: null, + payload: { collection: "" }, + }); + } + this.started = true; + } catch (error) { + if (this.client === client) { + this.client = null; + } + await client.end().catch(() => undefined); + this.report(error as Error); + this.scheduleRetry(); + } + } + + private disconnected(client: Client, error?: Error): void { + if (client !== this.client) { + return; + } + this.client = null; + if (error) { + this.report(error); + } + this.scheduleRetry(); + } + + private scheduleRetry(): void { + if (this.closed || this.retry) { + return; + } + this.retry = setTimeout( + () => { + this.retry = null; + void this.start(); + }, + 1000 + Math.random() * 2000, + ); + this.retry.unref(); + } + + subscribe(scope: PresenceScope, listener: RealtimeListener): () => void { + return this.local.subscribe(scope, listener); + } + + async publish(event: RealtimeEvent): Promise { + if (!this.client) { + throw new Error("Realtime listener unavailable"); + } + await this.client.query("SELECT pg_notify($1, $2)", [ + realtimeChannel, + JSON.stringify(event), + ]); + } + + async close(): Promise { + this.closed = true; + if (this.retry) { + clearTimeout(this.retry); + } + await this.client?.end(); + await this.local.close(); + } +} diff --git a/apps/core/src/realtime/locks.ts b/apps/core/src/realtime/locks.ts new file mode 100644 index 0000000..b53f559 --- /dev/null +++ b/apps/core/src/realtime/locks.ts @@ -0,0 +1,283 @@ +import type { Knex } from "knex"; +import type { Access } from "../permissions/access.js"; +import { requireGrant } from "../permissions/access.js"; +import { assertRowWrite } from "../permissions/row-access.js"; +import { collectionSchema } from "../items/schema-repository.js"; +import { findCollectionSettings } from "../collections/settings-repository.js"; +import { ItemError, parseCollectionName } from "../items/validation.js"; +import { presenceKey } from "../presence/access.js"; +import { parsePresenceClient } from "../presence/input.js"; +import { publishRealtime, realtimeEvent } from "./publish.js"; + +export const lockLeaseSeconds = 30; + +export interface LockInput { + collection: string; + recordId: string; + field: string; + clientId: string; +} + +export function parseLockInput(input: unknown): LockInput { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new ItemError("Invalid lock request", 400); + } + const value = input as Record; + if ( + Object.keys(value).some( + (key) => !["collection", "recordId", "field", "clientId"].includes(key), + ) + ) { + throw new ItemError("Invalid lock request", 400); + } + if ( + typeof value.collection !== "string" || + typeof value.recordId !== "string" || + value.recordId.length < 1 || + value.recordId.length > 255 || + typeof value.field !== "string" || + !/^[a-z][a-z0-9_]{0,62}$/.test(value.field) + ) { + throw new ItemError("Invalid lock request", 400); + } + return { + collection: parseCollectionName(value.collection), + recordId: value.recordId, + field: value.field, + clientId: parsePresenceClient(value.clientId), + }; +} + +async function authorizedLock(db: Knex, access: Access, input: LockInput) { + if (access.principal.kind !== "user") { + throw new ItemError("Human session required", 403); + } + const key = await presenceKey(db, access, { + kind: "record", + collection: input.collection, + id: input.recordId, + }); + const [, collectionId, recordId] = JSON.parse(key) as [ + string, + string, + string, + ]; + const schema = await collectionSchema(db, input.collection); + const fields = requireGrant(access, input.collection, "update"); + if ( + !schema.fields.has(input.field) || + (!fields.includes("*") && !fields.includes(input.field)) + ) { + throw new ItemError("Field cannot be edited", 403); + } + await assertRowWrite( + db, + access, + input.collection, + "update", + schema.settings.primaryKey.name, + recordId, + [input.field], + ); + return { collectionId, recordId }; +} + +export async function acquireLock(db: Knex, access: Access, input: LockInput) { + const { collectionId, recordId } = await authorizedLock(db, access, input); + if (access.principal.kind !== "user") { + throw new ItemError("Human session required", 403); + } + const user = access.principal; + const result = await db.raw<{ + rows: { expires_at: Date; created_at: Date }[]; + }>( + `INSERT INTO public.asmblyr_field_locks + (collection_id, record_id, field, session_id, client_id, user_id, expires_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP + ? * INTERVAL '1 second') + ON CONFLICT (collection_id, record_id, field) DO UPDATE SET + expires_at = EXCLUDED.expires_at, + session_id = EXCLUDED.session_id, + client_id = EXCLUDED.client_id, + user_id = EXCLUDED.user_id, + created_at = CASE WHEN asmblyr_field_locks.expires_at <= CURRENT_TIMESTAMP + THEN CURRENT_TIMESTAMP ELSE asmblyr_field_locks.created_at END + WHERE asmblyr_field_locks.expires_at <= CURRENT_TIMESTAMP OR + (asmblyr_field_locks.session_id = EXCLUDED.session_id AND + asmblyr_field_locks.client_id = EXCLUDED.client_id) + RETURNING expires_at, created_at`, + [ + collectionId, + recordId, + input.field, + user.sessionId, + input.clientId, + user.id, + lockLeaseSeconds, + ], + ); + if (!result.rows.length) { + throw new ItemError("Field is being edited", 409, "FIELD_LOCKED"); + } + const person = await db("public.asmblyr_users") + .where({ id: user.id }) + .first<{ display_name: string | null }>("display_name"); + const expiresAt = result.rows[0].expires_at.toISOString(); + await publishRealtime( + db, + realtimeEvent( + "field.locked", + { kind: "user", id: user.id }, + { + collection: input.collection, + recordId, + field: input.field, + holder: { + id: user.id, + displayName: person?.display_name || "Участник", + }, + expiresAt, + }, + ), + ); + return { + data: { + collection: input.collection, + recordId, + field: input.field, + userId: user.id, + sessionId: user.sessionId, + clientId: input.clientId, + createdAt: result.rows[0].created_at.toISOString(), + expiresAt, + }, + }; +} + +export async function releaseLock( + db: Knex, + access: Access, + input: LockInput, +): Promise { + if (access.principal.kind !== "user") { + throw new ItemError("Human session required", 403); + } + // A revoked editor still needs to release its own lease immediately. + const settings = await findCollectionSettings( + db, + parseCollectionName(input.collection), + ); + if (!settings) return; + const collectionId = settings.internalId; + const recordId = input.recordId; + const removed = await db("public.asmblyr_field_locks") + .where({ + collection_id: collectionId, + record_id: recordId, + field: input.field, + session_id: access.principal.sessionId, + client_id: input.clientId, + }) + .delete(); + if (removed) { + await publishRealtime( + db, + realtimeEvent( + "field.unlocked", + { kind: "user", id: access.principal.id }, + { + collection: input.collection, + recordId, + field: input.field, + holder: { id: access.principal.id, displayName: "" }, + expiresAt: new Date().toISOString(), + }, + ), + ); + } +} + +export async function releaseClientLocks( + db: Knex, + sessionId: string, + clientId: string, +): Promise { + const rows = await db("public.asmblyr_field_locks as lock") + .join( + "public.asmblyr_collections as collection", + "collection.id", + "lock.collection_id", + ) + .where({ "lock.session_id": sessionId, "lock.client_id": clientId }) + .select( + "collection.name", + "lock.record_id", + "lock.field", + "lock.user_id", + "lock.collection_id", + ); + for (const row of rows) { + const removed = await db("public.asmblyr_field_locks") + .where({ + collection_id: row.collection_id, + record_id: row.record_id, + field: row.field, + session_id: sessionId, + client_id: clientId, + }) + .delete(); + if (removed) { + await publishRealtime( + db, + realtimeEvent( + "field.unlocked", + { kind: "user", id: row.user_id }, + { + collection: row.name, + recordId: row.record_id, + field: row.field, + holder: { id: row.user_id, displayName: "" }, + expiresAt: new Date().toISOString(), + }, + ), + ); + } + } +} + +export async function expireLocks(db: Knex): Promise { + const expired = await db("public.asmblyr_field_locks") + .where("expires_at", "<=", db.fn.now()) + .delete() + .returning< + { + collection_id: string; + record_id: string; + field: string; + user_id: string; + }[] + >(["collection_id", "record_id", "field", "user_id"]); + if (!expired.length) return; + const collections = await db("public.asmblyr_collections") + .whereIn("id", [...new Set(expired.map((row) => row.collection_id))]) + .select<{ id: string; name: string }[]>("id", "name"); + const names = new Map(collections.map((row) => [row.id, row.name])); + for (const row of expired) { + const collection = names.get(row.collection_id); + if (collection) { + await publishRealtime( + db, + realtimeEvent( + "field.unlocked", + { kind: "user", id: row.user_id }, + { + collection, + recordId: row.record_id, + field: row.field, + holder: { id: row.user_id, displayName: "" }, + expiresAt: new Date().toISOString(), + }, + ), + ); + } + } +} diff --git a/apps/core/src/realtime/publish.ts b/apps/core/src/realtime/publish.ts new file mode 100644 index 0000000..88035a7 --- /dev/null +++ b/apps/core/src/realtime/publish.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; +import type { Knex } from "knex"; +import type { + RealtimeEvent, + RealtimeEnvelope, + RealtimePayloads, +} from "@asmblyr-collaborative/contracts"; +import { realtimeChannel } from "./bus.js"; + +export function realtimeEvent( + type: T, + actor: RealtimeEvent["actor"], + payload: RealtimePayloads[T], +): RealtimeEnvelope { + return { + id: randomUUID(), + type, + timestamp: new Date().toISOString(), + workspaceId: null, + actor, + payload, + }; +} + +export async function publishRealtime( + transaction: Knex | Knex.Transaction, + event: RealtimeEvent, +): Promise { + await transaction.raw("SELECT pg_notify(?, ?)", [ + realtimeChannel, + JSON.stringify(event), + ]); +} diff --git a/apps/core/src/realtime/routes.ts b/apps/core/src/realtime/routes.ts new file mode 100644 index 0000000..3d82c4c --- /dev/null +++ b/apps/core/src/realtime/routes.ts @@ -0,0 +1,33 @@ +import type { FastifyInstance } from "fastify"; +import type { Knex } from "knex"; +import { loadAccess } from "../permissions/access.js"; +import { ItemError } from "../items/validation.js"; +import { acquireLock, parseLockInput, releaseLock } from "./locks.js"; +import type { RealtimeBus } from "./bus.js"; +import { registerRealtimeStream } from "./stream.js"; + +export function registerRealtimeRoutes( + app: FastifyInstance, + database: Knex | null, + bus: RealtimeBus, +): void { + function db(): Knex { + if (!database) { + throw new ItemError("Database is not configured", 503); + } + return database; + } + + app.post("/realtime/locks", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const access = await loadAccess(db(), request.headers.authorization); + return acquireLock(db(), access, parseLockInput(request.body)); + }); + app.delete("/realtime/locks", async (request, reply) => { + const access = await loadAccess(db(), request.headers.authorization); + await releaseLock(db(), access, parseLockInput(request.body)); + return reply.header("Cache-Control", "no-store").code(204).send(); + }); + + registerRealtimeStream(app, database, bus); +} diff --git a/apps/core/src/realtime/stream.ts b/apps/core/src/realtime/stream.ts new file mode 100644 index 0000000..072fcee --- /dev/null +++ b/apps/core/src/realtime/stream.ts @@ -0,0 +1,359 @@ +import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; +import type { Knex } from "knex"; +import type { + PresenceScope, + RealtimeEvent, +} from "@asmblyr-collaborative/contracts"; +import { loadAccess, requireGrant } from "../permissions/access.js"; +import { presenceKey } from "../presence/access.js"; +import { parsePresenceInput } from "../presence/input.js"; +import { leavePresence, touchPresence } from "../presence/repository.js"; +import { ItemError } from "../items/validation.js"; +import { getItem } from "../items/service.js"; +import { releaseClientLocks } from "./locks.js"; +import type { RealtimeBus } from "./bus.js"; +import { realtimeEvent } from "./publish.js"; + +const streamHeartbeatMs = 15_000; +const presenceRenewalMs = 20_000; + +function sameScope(left: PresenceScope, right: PresenceScope): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function registerRealtimeStream( + app: FastifyInstance, + database: Knex | null, + bus: RealtimeBus, +): void { + const activeStreams = new Set<() => void>(); + let closing = false; + app.addHook("preClose", async () => { + closing = true; + for (const stop of activeStreams) { + stop(); + } + }); + + function db(): Knex { + if (!database) { + throw new ItemError("Database is not configured", 503); + } + return database; + } + + app.get<{ Querystring: { clientId?: string; scope?: string } }>( + "/realtime/stream", + async (request, reply) => { + const authorization = request.headers.authorization; + let access = await loadAccess(db(), authorization); + if (access.principal.kind !== "user") { + throw new ItemError("Human session required", 403); + } + if (!request.query.scope || request.query.scope.length > 1024) { + throw new ItemError("Invalid realtime scope", 400); + } + let scope: PresenceScope; + try { + scope = parsePresenceInput({ + clientId: request.query.clientId, + scope: JSON.parse(request.query.scope), + }).scope; + } catch (error) { + if (error instanceof ItemError) { + throw error; + } + throw new ItemError("Invalid realtime scope", 400); + } + const clientId = request.query.clientId!; + const principal = access.principal; + const connectionId = randomUUID(); + const openedAt = Date.now(); + let sent = 0; + const key = await presenceKey(db(), access, scope); + let initialLocks: RealtimeEvent[] = []; + if (scope.kind === "record") { + const collection = scope.collection; + const [, collectionId, recordId] = JSON.parse(key) as [ + string, + string, + string, + ]; + scope = { ...scope, id: recordId }; + const visible = await getItem( + db(), + scope.collection, + recordId, + requireGrant(access, scope.collection, "read"), + undefined, + access, + ); + const rows = await db()("public.asmblyr_field_locks as lock") + .join( + "public.asmblyr_auth_sessions as session", + "session.id", + "lock.session_id", + ) + .join("public.asmblyr_users as usr", "usr.id", "lock.user_id") + .where({ + "lock.collection_id": collectionId, + "lock.record_id": recordId, + "usr.status": "active", + }) + .where("lock.expires_at", ">", db().fn.now()) + .whereNull("session.revoked_at") + .where("session.expires_at", ">", db().fn.now()) + .select( + "lock.field", + "lock.user_id", + "lock.expires_at", + "usr.display_name", + ); + initialLocks = rows + .filter((row) => Object.hasOwn(visible, row.field)) + .map((row) => + realtimeEvent( + "field.locked", + { kind: "user", id: row.user_id }, + { + collection, + recordId, + field: row.field, + holder: { + id: row.user_id, + displayName: row.display_name || "Участник", + }, + expiresAt: row.expires_at.toISOString(), + }, + ), + ); + } + if (closing) { + throw new ItemError("Server is shutting down", 503); + } + const initial = await touchPresence(db(), principal, clientId, key); + if (closing) { + await leavePresence(db(), principal.sessionId, clientId); + throw new ItemError("Server is shutting down", 503); + } + + reply.hijack(); + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-store, no-transform", + "X-Accel-Buffering": "no", + Connection: "keep-alive", + }); + reply.raw.flushHeaders(); + let closed = false; + let pending = 0; + function send(event: RealtimeEvent): void { + if (closed || reply.raw.writableLength > 256_000) { + stop(); + return; + } + reply.raw.write(`id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`); + sent += 1; + } + function sendPresence(result: typeof initial): void { + send( + realtimeEvent("presence.changed", null, { + scope, + participants: result.data.participants, + total: result.data.total, + }), + ); + } + const unsubscribe = bus.subscribe(scope, (event) => { + if (closed || pending >= 32) { + if (pending >= 32) stop(); + return; + } + pending += 1; + void deliver(event).finally(() => { + pending -= 1; + }); + }); + async function deliver(event: RealtimeEvent): Promise { + try { + access = await loadAccess(db(), authorization); + if ( + access.principal.kind !== "user" || + access.principal.sessionId !== principal.sessionId + ) { + stop(); + return; + } + if (event.type === "presence.changed") { + if (sameScope(scope, event.payload.scope)) { + await presenceKey(db(), access, scope); + sendPresence(await touchPresence(db(), principal, clientId, key)); + } + return; + } + if ( + event.type === "collection.changed" && + event.payload.collection === "" + ) { + stop(); + return; + } + if (scope.kind === "page" || event.type === "collection.changed") { + return; + } + if (event.payload.collection !== scope.collection) { + return; + } + if (scope.kind === "collection") { + await presenceKey(db(), access, scope); + if ( + event.type === "record.deleted" && + access.rowRules?.has(`${scope.collection}:read`) + ) { + return; + } + if (event.type !== "record.deleted") { + try { + await getItem( + db(), + scope.collection, + event.payload.recordId, + requireGrant(access, scope.collection, "read"), + undefined, + access, + ); + } catch (error) { + if (error instanceof ItemError && error.statusCode === 404) { + return; + } + throw error; + } + } + send( + realtimeEvent("collection.changed", event.actor, { + collection: scope.collection, + }), + ); + return; + } + if (event.payload.recordId !== scope.id) { + return; + } + if (event.type === "record.deleted") { + // Deleted rows cannot be checked against conditional row grants. + requireGrant(access, scope.collection, "read"); + if (!access.rowRules?.has(`${scope.collection}:read`)) { + send(event); + } + return; + } + await presenceKey(db(), access, scope); + const readable = requireGrant(access, scope.collection, "read"); + const visible = await getItem( + db(), + scope.collection, + scope.id, + readable, + undefined, + access, + ); + if ( + event.type === "record.updated" || + event.type === "record.created" + ) { + const changedFields = event.payload.changedFields.filter((field) => + Object.hasOwn(visible, field), + ); + send({ ...event, payload: { ...event.payload, changedFields } }); + } else if (Object.hasOwn(visible, event.payload.field)) { + send(event); + } + } catch { + stop(); + } + } + function stop(): void { + if (closed) { + return; + } + closed = true; + activeStreams.delete(stop); + request.log.info( + { + connectionId, + userId: principal.id, + collection: scope.kind === "page" ? null : scope.collection, + scope: scope.kind, + sent, + durationMs: Date.now() - openedAt, + }, + "Realtime connection closed", + ); + unsubscribe(); + clearInterval(heartbeat); + clearInterval(renewal); + reply.raw.end(); + void releaseClientLocks(db(), principal.sessionId, clientId).catch( + () => undefined, + ); + void leavePresence(db(), principal.sessionId, clientId) + .then(() => + bus.publish( + realtimeEvent("presence.changed", null, { + scope, + participants: [], + total: 0, + }), + ), + ) + .catch(() => undefined); + } + const heartbeat = setInterval(() => { + if (!closed) reply.raw.write(": heartbeat\n\n"); + }, streamHeartbeatMs); + const renewal = setInterval(() => { + void loadAccess(db(), authorization) + .then((fresh) => { + if ( + fresh.principal.kind !== "user" || + fresh.principal.sessionId !== principal.sessionId + ) { + stop(); + return; + } + return presenceKey(db(), fresh, scope) + .then(() => touchPresence(db(), principal, clientId, key)) + .then((snapshot) => sendPresence(snapshot)); + }) + .catch(stop); + }, presenceRenewalMs); + activeStreams.add(stop); + reply.raw.on("close", stop); + if (reply.raw.destroyed) { + stop(); + return; + } + request.log.info( + { + connectionId, + userId: principal.id, + collection: scope.kind === "page" ? null : scope.collection, + scope: scope.kind, + }, + "Realtime connection opened", + ); + sendPresence(initial); + for (const lock of initialLocks) send(lock); + void bus + .publish( + realtimeEvent("presence.changed", null, { + scope, + participants: [], + total: 0, + }), + ) + .catch(() => stop()); + }, + ); +} diff --git a/apps/core/test/http-boundaries.test.ts b/apps/core/test/http-boundaries.test.ts index 315eba7..1f63cf3 100644 --- a/apps/core/test/http-boundaries.test.ts +++ b/apps/core/test/http-boundaries.test.ts @@ -22,12 +22,15 @@ test("unexpected errors hide database details and return a diagnostic request ID const response = await app.inject("/failure"); assert.equal(response.statusCode, 500); assert.equal(response.json().code, "INTERNAL_ERROR"); + assert.deepEqual(response.json().details, {}); assert.ok(response.json().requestId); assert.doesNotMatch( response.body, /SELECT|secret|private_table|password|42P01/, ); - assert.equal((await app.inject("/input")).json().message, "Invalid field"); + const invalid = (await app.inject("/input")).json(); + assert.equal(invalid.message, "Invalid field"); + assert.deepEqual(invalid.details, {}); } finally { await app.close(); } diff --git a/apps/core/test/realtime-bus.test.ts b/apps/core/test/realtime-bus.test.ts new file mode 100644 index 0000000..1b2bfa4 --- /dev/null +++ b/apps/core/test/realtime-bus.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { InMemoryRealtimeBus } from "../src/realtime/bus.js"; +import { realtimeEvent } from "../src/realtime/publish.js"; +import { parseLockInput } from "../src/realtime/locks.js"; + +test("in-memory bus stops delivery after unsubscribe and close", async () => { + const bus = new InMemoryRealtimeBus(); + const received: string[] = []; + const unsubscribe = bus.subscribe( + { kind: "collection", collection: "demo" }, + (event) => received.push(event.id), + ); + const event = realtimeEvent("collection.changed", null, { + collection: "demo", + }); + bus.publish(event); + unsubscribe(); + bus.publish(event); + assert.deepEqual(received, [event.id]); + await bus.close(); + bus.publish(event); + assert.deepEqual(received, [event.id]); +}); + +test("in-memory bus routes events to their scope once", async () => { + const bus = new InMemoryRealtimeBus(); + const collection: string[] = []; + const record: string[] = []; + const other: string[] = []; + bus.subscribe({ kind: "collection", collection: "demo" }, (event) => + collection.push(event.type), + ); + bus.subscribe({ kind: "record", collection: "demo", id: "7" }, (event) => + record.push(event.type), + ); + bus.subscribe({ kind: "collection", collection: "other" }, (event) => + other.push(event.type), + ); + await bus.publish( + realtimeEvent("record.updated", null, { + collection: "demo", + recordId: "7", + changedFields: ["title"], + revision: "9", + }), + ); + await bus.publish( + realtimeEvent("presence.changed", null, { + scope: { kind: "record", collection: "demo", id: "7" }, + participants: [], + total: 0, + }), + ); + await bus.publish( + realtimeEvent("field.locked", null, { + collection: "demo", + recordId: "7", + field: "title", + holder: { id: "editor", displayName: "Editor" }, + expiresAt: new Date().toISOString(), + }), + ); + await bus.publish( + realtimeEvent("collection.changed", null, { collection: "" }), + ); + assert.deepEqual(collection, ["record.updated", "collection.changed"]); + assert.deepEqual(record, [ + "record.updated", + "presence.changed", + "field.locked", + "collection.changed", + ]); + assert.deepEqual(other, ["collection.changed"]); +}); + +test("lock input rejects unbounded IDs and unknown fields before database work", () => { + const valid = { + collection: "articles", + recordId: "4", + field: "title", + clientId: "00000000-0000-4000-8000-000000000001", + }; + assert.deepEqual(parseLockInput(valid), valid); + assert.throws(() => parseLockInput({ ...valid, recordId: "x".repeat(256) }), { + statusCode: 400, + }); + assert.throws(() => parseLockInput({ ...valid, sessionId: valid.clientId }), { + statusCode: 400, + }); + assert.throws(() => parseLockInput({ ...valid, field: "title;DROP TABLE" }), { + statusCode: 400, + }); +}); diff --git a/apps/core/test/realtime.integration.test.ts b/apps/core/test/realtime.integration.test.ts new file mode 100644 index 0000000..7ac433b --- /dev/null +++ b/apps/core/test/realtime.integration.test.ts @@ -0,0 +1,294 @@ +import "./support/require-test-database.js"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { createClient } from "@asmblyr-collaborative/sdk"; +import type { RealtimeEvent } from "@asmblyr-collaborative/contracts"; +import { createBrowserSession } from "../src/auth/browser/sessions.js"; +import { issueUserTokens } from "../src/auth/tokens.js"; +import { pluginItemsFixture } from "./support/plugin-items-fixture.js"; + +function eventOf( + type: RealtimeEvent["type"], + subscribe: (callback: (event: RealtimeEvent) => void) => () => void, + matches: (event: RealtimeEvent) => boolean = () => true, + timeoutMs = 5000, +) { + let release: () => void = () => undefined; + const result = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for ${type}`)), + timeoutMs, + ); + release = subscribe((event) => { + if (event.type === type && matches(event)) { + clearTimeout(timer); + resolve(event); + } + }); + }); + return { result, release: () => release() }; +} + +test("realtime stream shares committed updates and enforces record access", async (t) => { + const f = await pluginItemsFixture(); + let closeLive: (() => void) | null = null; + t.after(async () => { + closeLive?.(); + await f.close(); + }); + const url = await f.app.listen({ host: "127.0.0.1", port: 0 }); + const client = createClient({ baseUrl: url, accessToken: f.memberToken }); + const live = client.realtime.connect(); + closeLive = () => live.close(); + const scope = { kind: "record" as const, collection: f.collection, id: "2" }; + const listeners = new Set<(event: RealtimeEvent) => void>(); + const listen = (callback: (event: RealtimeEvent) => void) => { + listeners.add(callback); + return () => listeners.delete(callback); + }; + const connected = eventOf("presence.changed", listen); + const unsubscribe = live.subscribe(scope, (event) => { + for (const listener of listeners) listener(event); + }); + await connected.result; + const update = eventOf("record.updated", listen); + await f.call("PATCH", `/items/${f.collection}/2`, { title: "Live change" }); + const received = await update.result; + assert.equal(received.actor?.id, f.admin.id); + if (received.type === "record.updated") { + assert.equal(received.payload.recordId, "2"); + assert.ok(received.payload.changedFields.includes("title")); + assert.ok(!received.payload.changedFields.includes("secret")); + assert.match(received.payload.revision, /^\d+$/); + } + const deletion = eventOf("record.deleted", listen); + await f.call("DELETE", `/items/${f.collection}/2`, undefined, 204); + const deleted = await deletion.result; + assert.equal(deleted.type, "record.deleted"); + deletion.release(); + connected.release(); + update.release(); + unsubscribe(); + + const unauthorized = await fetch( + `${url}/realtime/stream?${new URLSearchParams({ clientId: randomUUID(), scope: JSON.stringify(scope) })}`, + { + headers: { authorization: `Bearer ${f.outsiderToken}` }, + }, + ); + assert.equal(unauthorized.status, 403); + await unauthorized.body?.cancel(); +}); + +test("closing Core drains an open realtime stream", async (t) => { + const f = await pluginItemsFixture(); + const active: { + stream?: Response; + closePromise?: Promise; + } = {}; + t.after(async () => { + await active.stream?.body?.cancel().catch(() => undefined); + if (active.closePromise) { + await active.closePromise; + } else { + await f.app.close(); + } + await f.db.destroy(); + }); + const url = await f.app.listen({ host: "127.0.0.1", port: 0 }); + const scope = JSON.stringify({ + kind: "record", + collection: f.collection, + id: "2", + }); + const stream = await fetch( + `${url}/realtime/stream?${new URLSearchParams({ clientId: randomUUID(), scope })}`, + { headers: { authorization: `Bearer ${f.adminToken}` } }, + ); + active.stream = stream; + assert.equal(stream.status, 200); + + const closePromise = f.app.close(); + active.closePromise = closePromise; + const closed = await Promise.race([ + closePromise.then(() => true), + new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 5000); + timeout.unref(); + }), + ]); + assert.equal(closed, true, "Core must close without client cancellation"); +}); + +test("presence renewal removes expired participants from the open stream", async (t) => { + const f = await pluginItemsFixture(); + const live = createClient({ + baseUrl: await f.app.listen({ host: "127.0.0.1", port: 0 }), + accessToken: f.adminToken, + }).realtime.connect(); + t.after(async () => { + live.close(); + await f.close(); + }); + const scope = { kind: "record" as const, collection: f.collection, id: "2" }; + const ghostId = randomUUID(); + await f.call( + "POST", + "/presence", + { clientId: ghostId, scope }, + 200, + f.memberToken, + ); + const listeners = new Set<(event: RealtimeEvent) => void>(); + const listen = (callback: (event: RealtimeEvent) => void) => { + listeners.add(callback); + return () => listeners.delete(callback); + }; + const joined = eventOf( + "presence.changed", + listen, + (event) => + event.type === "presence.changed" && + event.payload.participants.some((person) => person.id === f.member.id), + ); + const unsubscribe = live.subscribe(scope, (event) => { + for (const listener of listeners) { + listener(event); + } + }); + t.after(unsubscribe); + await joined.result; + joined.release(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + await f + .db("asmblyr_presence") + .where({ client_id: ghostId }) + .update({ expires_at: new Date(0) }); + const expired = eventOf( + "presence.changed", + listen, + (event) => + event.type === "presence.changed" && + !event.payload.participants.some((person) => person.id === f.member.id), + 35_000, + ); + await expired.result; + expired.release(); +}); + +test("field locks are atomic, scoped to editor sessions and expire", async (t) => { + const f = await pluginItemsFixture(); + let closeLive: (() => void) | null = null; + t.after(async () => { + closeLive?.(); + await f.close(); + }); + await f.grant(f.collection, ["title"], f.member.id, "update"); + const member = { + collection: f.collection, + recordId: "2", + field: "title", + clientId: randomUUID(), + }; + const admin = { ...member, clientId: randomUUID() }; + await f.call("POST", "/realtime/locks", member, 200, f.memberToken); + const url = await f.app.listen({ host: "127.0.0.1", port: 0 }); + const live = createClient({ + baseUrl: url, + accessToken: f.adminToken, + }).realtime.connect(); + closeLive = () => live.close(); + const initialLock = eventOf("field.locked", (callback) => + live.subscribe( + { kind: "record", collection: f.collection, id: "2" }, + callback, + ), + ); + const seen = await initialLock.result; + assert.equal( + seen.type === "field.locked" && seen.payload.holder.id, + f.member.id, + ); + initialLock.release(); + await f.call("POST", "/realtime/locks", admin, 409, f.adminToken); + await f.call( + "POST", + "/realtime/locks", + { ...member, field: "secret" }, + 403, + f.memberToken, + ); + await f.call("POST", "/realtime/locks", member, 403, f.outsiderToken); + await f.call("DELETE", "/realtime/locks", admin, 204, f.adminToken); + assert.equal( + (await f.db("asmblyr_field_locks").where({ field: "title" })).length, + 1, + ); + await f + .db("asmblyr_field_locks") + .where({ field: "title" }) + .update({ expires_at: new Date(0) }); + await f.call("POST", "/realtime/locks", admin, 200, f.adminToken); + await f.call("DELETE", "/realtime/locks", admin, 204, f.adminToken); + assert.equal( + (await f.db("asmblyr_field_locks").where({ field: "title" })).length, + 0, + ); +}); + +test("browser session reaches realtime routes through Core /api and enforces origin", async (t) => { + const f = await pluginItemsFixture(); + t.after(f.close); + const token = await createBrowserSession( + f.db, + await issueUserTokens(f.db, f.admin.id), + ); + const url = await f.app.listen({ host: "127.0.0.1", port: 0 }); + const input = { + collection: f.collection, + recordId: "2", + field: "title", + clientId: randomUUID(), + }; + const headers = { + cookie: `asmblyr_session=${token}`, + "content-type": "application/json", + }; + const lockUrl = `${url}/api/realtime/locks`; + const denied = await fetch(lockUrl, { + method: "POST", + headers, + body: JSON.stringify(input), + }); + assert.equal(denied.status, 403); + + const browserHeaders = { ...headers, origin: "http://localhost:3000" }; + const acquired = await fetch(lockUrl, { + method: "POST", + headers: browserHeaders, + body: JSON.stringify(input), + }); + assert.equal(acquired.status, 200, await acquired.clone().text()); + + const scope = JSON.stringify({ + kind: "record", + collection: f.collection, + id: "2", + }); + const stream = await fetch( + `${url}/api/realtime/stream?${new URLSearchParams({ clientId: input.clientId, scope })}`, + { headers }, + ); + assert.equal(stream.status, 200); + assert.match(stream.headers.get("content-type") ?? "", /text\/event-stream/); + await stream.body?.cancel(); + + const released = await fetch(lockUrl, { + method: "DELETE", + headers: browserHeaders, + body: JSON.stringify(input), + }); + assert.equal(released.status, 204); +}); diff --git a/apps/ui/src/app/fonts/OFL.txt b/apps/ui/src/app/fonts/OFL.txt new file mode 100644 index 0000000..42f8647 --- /dev/null +++ b/apps/ui/src/app/fonts/OFL.txt @@ -0,0 +1,82 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/ui/src/app/fonts/geist-latin.woff2 b/apps/ui/src/app/fonts/geist-latin.woff2 new file mode 100644 index 0000000..445e0e5 Binary files /dev/null and b/apps/ui/src/app/fonts/geist-latin.woff2 differ diff --git a/apps/ui/src/app/layout.tsx b/apps/ui/src/app/layout.tsx index 496c2ee..6514901 100644 --- a/apps/ui/src/app/layout.tsx +++ b/apps/ui/src/app/layout.tsx @@ -1,13 +1,18 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Geist } from "next/font/google"; +import localFont from "next/font/local"; import { ThemeProvider } from "next-themes"; import { cn } from "@/lib/utils"; import { cookies } from "next/headers"; import { themeStyles, type ThemeStyle } from "@asmblyr-collaborative/contracts"; import { AppearanceProvider } from "@/components/settings/appearance-provider"; -const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); +const geist = localFont({ + src: "./fonts/geist-latin.woff2", + variable: "--font-sans", + weight: "100 900", + display: "swap", +}); export const metadata: Metadata = { title: "Asmblyr", diff --git a/apps/ui/src/components/items/item-editor-dialog.tsx b/apps/ui/src/components/items/item-editor-dialog.tsx index b8610b7..02e1dbf 100644 --- a/apps/ui/src/components/items/item-editor-dialog.tsx +++ b/apps/ui/src/components/items/item-editor-dialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { useRouter } from "next/navigation"; import { ApiError } from "@asmblyr-collaborative/sdk"; import { readEditorItem } from "@/lib/item-read"; @@ -15,6 +16,7 @@ import { serializeDraft, upsertRecord, withFormValues, + withRecordSnapshot, type RecordDraft, } from "./record-draft-model"; import { @@ -56,6 +58,15 @@ export function ItemEditorDialog({ const [draft, setDraft] = useState( () => request.draft ?? { id: request.id, values: {} }, ); + const dirtyRef = useRef( + Boolean( + request.extraDirty || (request.draft && draftChanges(request.draft)), + ), + ); + const loadedRecordRef = useRef(null); + const onDirtyChange = useCallback((dirty: boolean) => { + dirtyRef.current = dirty; + }, []); useEffect(() => { if (!request.id) return; const controller = new AbortController(); @@ -66,17 +77,36 @@ export function ItemEditorDialog({ request.itemEndpoint, ) .then((result) => { - if (!controller.signal.aborted) { + if (controller.signal.aborted) { + return; + } + const recordKey = JSON.stringify([ + request.collection, + request.id, + request.itemEndpoint, + ]); + const initialLoad = loadedRecordRef.current !== recordKey; + if (!initialLoad && (dirtyRef.current || request.extraDirty)) { + setError( + copy( + "Запись изменена другим участником. При сохранении проверьте конфликт.", + ), + ); + return; + } + // Commit the snapshot and its conflict baseline before another input event. + flushSync(() => { setItem(result.data); + setFormRevision((value) => value + 1); setDraft((current) => ({ - ...current, - baseValues: current.baseValues ?? result.data, + ...withRecordSnapshot(current, result.data, initialLoad), collection: request.collection, itemEndpoint: request.itemEndpoint, })); setLabel(result.label ?? request.title ?? ""); setError(""); - } + }); + loadedRecordRef.current = recordKey; }) .catch((cause: unknown) => { if (!controller.signal.aborted) @@ -87,6 +117,7 @@ export function ItemEditorDialog({ return () => controller.abort(); }, [ request.collection, + request.extraDirty, request.itemEndpoint, request.id, request.title, @@ -312,6 +343,7 @@ export function ItemEditorDialog({ title={(draft.label ?? label) || undefined} draft={draft} onDraftChange={setDraft} + onDirtyChange={onDirtyChange} draftMode={Boolean(request.onDraft)} onRetry={() => { setError(""); diff --git a/apps/ui/src/components/items/item-form-field.tsx b/apps/ui/src/components/items/item-form-field.tsx index e3e1d94..0a61b83 100644 --- a/apps/ui/src/components/items/item-form-field.tsx +++ b/apps/ui/src/components/items/item-form-field.tsx @@ -21,6 +21,7 @@ export function ItemFormField({ onBusy, disabled, container, + holder, }: { field: CollectionField; item?: Item; @@ -35,6 +36,7 @@ export function ItemFormField({ onBusy(busy: boolean): void; disabled: boolean; container?: HTMLElement | null; + holder?: string; }) { const copy = useUiCopy(); @@ -45,6 +47,11 @@ export function ItemFormField({ className={field.presentation?.label ? undefined : "font-mono"} > {field.presentation?.label || field.name} + {holder && ( + + {copy("{{value0}} редактирует", { value0: holder })} + + )} {!readonly && required && field.defaultValue === undefined && ( * )} diff --git a/apps/ui/src/components/items/item-form.tsx b/apps/ui/src/components/items/item-form.tsx index 88be322..bad5ee0 100644 --- a/apps/ui/src/components/items/item-form.tsx +++ b/apps/ui/src/components/items/item-form.tsx @@ -46,6 +46,9 @@ interface ItemFormProps { initialValues?: Item; allowUnchanged?: boolean; onReferenceChange?: (field: string, value: string) => void; + onFieldFocus?: (field: string) => void; + onFieldBlur?: (field: string) => void; + fieldHolders?: Record; } export function ItemForm({ @@ -72,6 +75,9 @@ export function ItemForm({ initialValues, allowUnchanged = false, onReferenceChange, + onFieldFocus, + onFieldBlur, + fieldHolders, }: ItemFormProps) { const copy = useUiCopy(); @@ -94,12 +100,16 @@ export function ItemForm({ const writable = fields.filter( (f) => !readOnlyFields.includes(f.name) && !fieldIsReadonly(f), ); - const changedCount = - writable.filter((field) => - item - ? values[field.name] !== inputValue(field, item) - : values[field.name] !== "", - ).length + (!item && manualKey ? 1 : 0); + function changedFieldCount(candidateValues: Record): number { + return ( + writable.filter((field) => + item + ? candidateValues[field.name] !== inputValue(field, item) + : candidateValues[field.name] !== "", + ).length + (!item && manualKey ? 1 : 0) + ); + } + const changedCount = changedFieldCount(values); useLayoutEffect(() => { onDirtyChange?.(changedCount); }, [changedCount, onDirtyChange]); @@ -220,6 +230,22 @@ export function ItemForm({ id={id} noValidate onSubmit={submit} + onFocusCapture={(event) => { + const field = (event.target as HTMLElement).closest( + "[data-form-field]", + )?.dataset.formField; + if (field && writable.some((entry) => entry.name === field)) + onFieldFocus?.(field); + }} + onBlurCapture={(event) => { + const field = (event.target as HTMLElement).closest( + "[data-form-field]", + )?.dataset.formField; + const next = ( + event.relatedTarget as HTMLElement | null + )?.closest("[data-form-field]")?.dataset.formField; + if (field && field !== next) onFieldBlur?.(field); + }} className={ embedded ? "space-y-5" : "space-y-4 rounded-xl border bg-card p-5" } @@ -291,6 +317,7 @@ export function ItemForm({ )) ) : ( void; draft: RecordDraft; onDraftChange: (draft: RecordDraft) => void; + onDirtyChange?: (dirty: boolean) => void; draftMode?: boolean; onReferenceChange: (field: string, value: string) => void; onSave: ( @@ -111,8 +121,12 @@ export function ItemRecordDialog({ }, [id, addressedPanel, targetId, visitPanel]); const busy = pending || uploading || extensions.busy; const relationChanges = draftChanges({ ...draft, values: {} }); - const recordDirty = changedCount > 0 || relationChanges > 0; + const recordDirty = changedCount > 0 || draftChanges(draft) > 0; const dirty = recordDirty || extensions.dirty; + useLayoutEffect(() => { + onDirtyChange?.(dirty || busy); + }, [busy, dirty, onDirtyChange]); + const live = useRecordLive(collection.name, id, dirty, onRetry); const formId = useId(); const readable = (name: string) => collection.access.read?.includes("*") || @@ -264,6 +278,21 @@ export function ItemRecordDialog({ className="space-y-7" > {conflictReview} +
+ {live.state === "connected" + ? copy("В сети") + : live.state === "reconnecting" + ? copy("Переподключение…") + : live.state === "offline" + ? copy("Нет соединения") + : copy("Подключение…")} + {live.notice && ( + {live.notice} + )} +
{collection.sourceKind === "materialized-view" ? ( holder.id !== live.selfId) + .map(([field, holder]) => [field, holder.displayName]), + )} portalContainer={portalContainer} onBusyChange={setUploading} - onDirtyChange={setChangedCount} + onDirtyChange={(count) => { + setChangedCount(count); + onDirtyChange?.( + count > 0 || + draftChanges(draft) > 0 || + extensions.dirty || + busy, + ); + }} onSubmitAttempt={() => setSection("data")} onSave={(values) => onSave(values, close)} onCancel={close} diff --git a/apps/ui/src/components/items/items-workspace.tsx b/apps/ui/src/components/items/items-workspace.tsx index ce46ee7..d225d5e 100644 --- a/apps/ui/src/components/items/items-workspace.tsx +++ b/apps/ui/src/components/items/items-workspace.tsx @@ -2,7 +2,7 @@ import { useLocalizedCatalog } from "./use-localized-catalog"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Plus } from "lucide-react"; import type { TablePreferences } from "@/lib/table-preferences"; @@ -30,6 +30,8 @@ import { useWorkspace } from "@/components/workspaces/workspace-provider"; import { AssistantTableContext } from "@/components/assistant/assistant-context"; import { useUiCopy } from "@/lib/ui-copy"; import { Badge } from "@/components/ui/badge"; +import { PresenceAvatars } from "@/components/presence/presence-avatars"; +import { subscribeRealtime } from "@/lib/realtime-scope"; export function ItemsWorkspace({ collection: sourceCollection, @@ -104,6 +106,29 @@ export function ItemsWorkspace({ setMessage, }); const canRead = Boolean(collection.access.read); + useEffect(() => { + if (!canRead) return; + let timer: ReturnType | null = null; + const unsubscribe = subscribeRealtime( + { kind: "collection", collection: collection.name }, + (event, state) => { + if ( + event?.type === "collection.changed" && + state === "connected" && + !timer + ) { + timer = setTimeout(() => { + timer = null; + router.refresh(); + }, 200); + } + }, + ); + return () => { + unsubscribe(); + if (timer) clearTimeout(timer); + }; + }, [canRead, collection.name, router]); const itemKey = (item: Item) => String(item[collection.primaryKey.name]); const canCreate = !collection.profileExtension && @@ -206,6 +231,9 @@ export function ItemsWorkspace({ ) : (
+ {collection.sourceKind === "materialized-view" && ( {copy("Только просмотр")} )} diff --git a/apps/ui/src/components/items/record-draft-model.ts b/apps/ui/src/components/items/record-draft-model.ts index fa5c607..3f713ce 100644 --- a/apps/ui/src/components/items/record-draft-model.ts +++ b/apps/ui/src/components/items/record-draft-model.ts @@ -121,6 +121,17 @@ export function withFormValues(draft: RecordDraft, values: Item): RecordDraft { }; } +export function withRecordSnapshot( + draft: RecordDraft, + item: Item, + initialLoad: boolean, +): RecordDraft { + return { + ...draft, + baseValues: initialLoad ? (draft.baseValues ?? item) : item, + }; +} + export function upsertRecord( draft: RecordDraft, collection: string, diff --git a/apps/ui/src/components/items/use-record-live.ts b/apps/ui/src/components/items/use-record-live.ts new file mode 100644 index 0000000..bc2bda8 --- /dev/null +++ b/apps/ui/src/components/items/use-record-live.ts @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { RealtimeConnectionState } from "@asmblyr-collaborative/contracts"; +import { ApiError } from "@asmblyr-collaborative/sdk"; +import { asmblyr } from "@/lib/asmblyr"; +import { subscribeRealtime } from "@/lib/realtime-scope"; +import { useUiCopy } from "@/lib/ui-copy"; + +export function useRecordLive( + collection: string, + recordId: string, + dirty: boolean, + refresh: () => void, +) { + const copy = useUiCopy(); + const [state, setState] = useState("connecting"); + const [notice, setNotice] = useState(""); + const [holders, setHolders] = useState< + Record + >({}); + const [selfId, setSelfId] = useState(""); + const dirtyRef = useRef(dirty); + const refreshRef = useRef(refresh); + useEffect(() => { + dirtyRef.current = dirty; + refreshRef.current = refresh; + }, [dirty, refresh]); + useEffect(() => { + if (!recordId) return; + return subscribeRealtime( + { kind: "record", collection, id: recordId }, + (event, connection) => { + setState(connection); + if (!event) { + if (connection !== "connected") setHolders({}); + return; + } + if (event.type === "presence.changed") { + setSelfId( + event.payload.participants.find((person) => person.self)?.id ?? "", + ); + } + if ( + event.type === "record.updated" || + event.type === "record.created" + ) { + if (dirtyRef.current) + setNotice( + copy( + "Запись изменена другим участником. При сохранении проверьте конфликт.", + ), + ); + else { + setNotice(copy("Запись обновлена")); + refreshRef.current(); + } + } + if (event.type === "record.deleted") + setNotice(copy("Запись удалена другим участником.")); + if (event.type === "field.locked") { + setHolders((current) => ({ + ...current, + [event.payload.field]: event.payload.holder, + })); + } + if (event.type === "field.unlocked") { + setHolders((current) => { + if (current[event.payload.field]?.id !== event.payload.holder.id) { + return current; + } + const next = { ...current }; + delete next[event.payload.field]; + return next; + }); + } + if ( + event.type === "collection.changed" && + connection === "connected" && + !dirtyRef.current + ) { + refreshRef.current(); + } + }, + ); + }, [collection, recordId, copy]); + + const live = useRef | null>(null); + const clientId = useRef(""); + const timers = useRef(new Map>()); + const focused = useRef(new Set()); + const pendingLocks = useRef(new Set()); + useEffect(() => { + clientId.current = crypto.randomUUID(); + live.current = asmblyr.realtime.connect(); + const active = live.current; + const lease = clientId.current; + const activeTimers = timers.current; + const activeFields = focused.current; + return () => { + activeFields.clear(); + for (const [field, timer] of activeTimers) { + clearInterval(timer); + void active.locks + .release({ collection, recordId, field, clientId: lease }) + .catch(() => undefined); + } + activeTimers.clear(); + active.close(); + live.current = null; + }; + }, [collection, recordId]); + + async function focus(field: string) { + const connection = live.current; + if (!connection) return; + focused.current.add(field); + if (timers.current.has(field) || pendingLocks.current.has(field)) return; + pendingLocks.current.add(field); + const lock = { collection, recordId, field, clientId: clientId.current }; + try { + await connection.locks.acquire(lock); + if (!focused.current.has(field)) { + await connection.locks.release(lock); + return; + } + const timer = setInterval(() => { + void connection.locks + .refresh(lock) + .then(() => { + setNotice((current) => + current === + copy( + "Блокировка поля истекла. Проверьте изменения перед сохранением.", + ) + ? "" + : current, + ); + }) + .catch(() => { + setNotice( + copy( + "Блокировка поля истекла. Проверьте изменения перед сохранением.", + ), + ); + }); + }, 10_000); + timers.current.set(field, timer); + } catch (error) { + focused.current.delete(field); + if (error instanceof ApiError && error.code === "FIELD_LOCKED") { + setNotice(copy("Это поле сейчас редактирует другой участник.")); + } + } finally { + pendingLocks.current.delete(field); + } + } + + function blur(field: string) { + focused.current.delete(field); + const timer = timers.current.get(field); + if (!timer) return; + clearInterval(timer); + timers.current.delete(field); + void live.current?.locks + .release({ collection, recordId, field, clientId: clientId.current }) + .catch(() => undefined); + } + + return { state, notice, holders, selfId, focus, blur }; +} diff --git a/apps/ui/src/components/presence/use-presence.ts b/apps/ui/src/components/presence/use-presence.ts index c2abbd4..6db9633 100644 --- a/apps/ui/src/components/presence/use-presence.ts +++ b/apps/ui/src/components/presence/use-presence.ts @@ -4,8 +4,7 @@ import type { PresenceResult, PresenceScope, } from "@asmblyr-collaborative/contracts"; -import { asmblyr } from "@/lib/asmblyr"; -import { createPresenceSession } from "./presence-session"; +import { subscribeRealtime } from "@/lib/realtime-scope"; export function usePresence( scope: PresenceScope | null, @@ -20,51 +19,19 @@ export function usePresence( return; } const target: PresenceScope = JSON.parse(key); - function start() { - const clientId = crypto.randomUUID(); - return createPresenceSession( - () => - asmblyr.presence.touch( - { clientId, scope: target }, - { timeoutMs: 5000 }, - ), - () => - fetch(`/api/presence/${clientId}`, { - method: "DELETE", - keepalive: true, - }), - (result) => setSnapshot({ key, data: result?.data ?? null }), - ); - } - let session = start(); - void session.refresh(); - const refresh = () => { - void session.refresh(); - }; - const hide = () => session.dispose(); - const show = (event: PageTransitionEvent) => { - if (event.persisted) { - session = start(); - refresh(); + const unsubscribe = subscribeRealtime(target, (event) => { + if (event?.type === "presence.changed") { + setSnapshot({ + key, + data: { + participants: event.payload.participants, + total: event.payload.total, + }, + }); } - }; - const visibility = () => { - if (document.visibilityState === "visible") { - refresh(); - } - }; - const timer = window.setInterval(refresh, 5000); - window.addEventListener("focus", refresh); - window.addEventListener("pagehide", hide); - window.addEventListener("pageshow", show); - document.addEventListener("visibilitychange", visibility); + }); return () => { - window.clearInterval(timer); - window.removeEventListener("focus", refresh); - window.removeEventListener("pagehide", hide); - window.removeEventListener("pageshow", show); - document.removeEventListener("visibilitychange", visibility); - session.dispose(); + unsubscribe(); }; }, [key]); return snapshot?.key === key ? snapshot.data : null; diff --git a/apps/ui/src/lib/asmblyr.ts b/apps/ui/src/lib/asmblyr.ts index ba05465..aca3bc8 100644 --- a/apps/ui/src/lib/asmblyr.ts +++ b/apps/ui/src/lib/asmblyr.ts @@ -1,4 +1,4 @@ import { createClient } from "@asmblyr-collaborative/sdk"; -/** Same-origin requests use the UI's HttpOnly session cookies and refresh proxy. */ +/** Same-origin requests use HttpOnly session cookies handled by Core. */ export const asmblyr = createClient({ baseUrl: "/api" }); diff --git a/apps/ui/src/lib/realtime-scope.ts b/apps/ui/src/lib/realtime-scope.ts new file mode 100644 index 0000000..4225b31 --- /dev/null +++ b/apps/ui/src/lib/realtime-scope.ts @@ -0,0 +1,68 @@ +import type { + PresenceScope, + RealtimeConnectionState, + RealtimeEvent, +} from "@asmblyr-collaborative/contracts"; +import { asmblyr } from "./asmblyr"; + +type Listener = ( + event: RealtimeEvent | null, + state: RealtimeConnectionState, +) => void; +const scopes = new Map< + string, + { + listeners: Set; + close: () => void; + state: RealtimeConnectionState; + presence: Extract | null; + } +>(); + +/** One browser stream per scope, shared by presence and record editors. */ +export function subscribeRealtime( + scope: PresenceScope, + listener: Listener, +): () => void { + const key = JSON.stringify(scope); + let active = scopes.get(key); + if (!active) { + const connection = asmblyr.realtime.connect(); + const listeners = new Set(); + active = { + listeners, + state: connection.state, + presence: null, + close: () => connection.close(), + }; + const entry = active; + const unsubscribe = connection.subscribe(scope, (event) => { + if (event.type === "presence.changed") { + entry.presence = event; + } + for (const subscriber of listeners) subscriber(event, entry.state); + }); + const unsubscribeState = connection.onState((state) => { + entry.state = state; + if (state !== "connected") { + entry.presence = null; + } + for (const subscriber of listeners) subscriber(null, state); + }); + entry.close = () => { + unsubscribeState(); + unsubscribe(); + connection.close(); + }; + scopes.set(key, entry); + } + active.listeners.add(listener); + listener(active.presence, active.state); + return () => { + active.listeners.delete(listener); + if (!active.listeners.size) { + active.close(); + scopes.delete(key); + } + }; +} diff --git a/apps/ui/test/realtime-scope.test.ts b/apps/ui/test/realtime-scope.test.ts new file mode 100644 index 0000000..55952e5 --- /dev/null +++ b/apps/ui/test/realtime-scope.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import type { + PresenceScope, + RealtimeConnectionState, + RealtimeEvent, +} from "@asmblyr-collaborative/contracts"; +import { asmblyr } from "../src/lib/asmblyr"; +import { subscribeRealtime } from "../src/lib/realtime-scope"; + +const scope: PresenceScope = { + kind: "record", + collection: "articles", + id: "4", +}; +const presence: RealtimeEvent = { + id: "presence", + type: "presence.changed", + timestamp: "2026-10-09T00:00:00.000Z", + workspaceId: null, + actor: null, + payload: { + scope, + participants: [ + { + id: "reader", + displayName: "Reader", + pictureUrl: null, + self: false, + views: 1, + }, + ], + total: 1, + }, +}; + +function setup(t: TestContext) { + let state: RealtimeConnectionState = "connecting"; + let receive: (event: RealtimeEvent) => void = () => {}; + let updateState: (state: RealtimeConnectionState) => void = () => {}; + const close = t.mock.fn(); + const unsubscribe = t.mock.fn(); + const unsubscribeState = t.mock.fn(); + const connect = t.mock.method(asmblyr.realtime, "connect", () => ({ + get state() { + return state; + }, + subscribe(_scope: PresenceScope, listener: typeof receive) { + receive = listener; + return unsubscribe; + }, + onState(listener: typeof updateState) { + updateState = listener; + listener(state); + return unsubscribeState; + }, + close, + })); + const cleanups = new Set<() => void>(); + t.after(() => cleanups.forEach((cleanup) => cleanup())); + return { + close, + connect, + unsubscribe, + unsubscribeState, + emit: (event: RealtimeEvent) => receive(event), + setState(next: RealtimeConnectionState) { + state = next; + updateState(next); + }, + subscribe() { + const listener = t.mock.fn(); + const unsubscribe = subscribeRealtime(scope, listener); + const stop = () => { + if (cleanups.delete(stop)) { + unsubscribe(); + } + }; + cleanups.add(stop); + return { listener, stop }; + }, + }; +} + +test("late subscribers immediately receive current state and the latest presence, not past mutations", (t) => { + const live = setup(t); + const first = live.subscribe(); + assert.deepEqual(first.listener.mock.calls[0].arguments, [ + null, + "connecting", + ]); + live.setState("connected"); + live.emit(presence); + const latest: RealtimeEvent = { + ...presence, + id: "empty-presence", + payload: { scope, participants: [], total: 0 }, + }; + live.emit(latest); + live.emit({ + id: "mutation", + type: "collection.changed", + timestamp: presence.timestamp, + workspaceId: null, + actor: null, + payload: { collection: "articles" }, + }); + + const late = live.subscribe(); + assert.equal(live.connect.mock.callCount(), 1); + assert.equal(late.listener.mock.callCount(), 1); + assert.deepEqual(late.listener.mock.calls[0].arguments, [ + latest, + "connected", + ]); + live.emit(presence); + assert.deepEqual(late.listener.mock.calls[1].arguments, [ + presence, + "connected", + ]); +}); + +test("subscribers joining after a disconnect do not receive stale presence", (t) => { + const live = setup(t); + live.subscribe(); + live.setState("connected"); + live.emit(presence); + live.setState("reconnecting"); + const late = live.subscribe(); + assert.deepEqual(late.listener.mock.calls[0].arguments, [ + null, + "reconnecting", + ]); + live.setState("connected"); + const restored = live.subscribe(); + assert.deepEqual(restored.listener.mock.calls[0].arguments, [ + null, + "connected", + ]); + live.emit(presence); + assert.deepEqual(restored.listener.mock.calls[1].arguments, [ + presence, + "connected", + ]); +}); + +test("the last subscriber closes the shared stream and a new subscription starts without cached presence", (t) => { + const live = setup(t); + const first = live.subscribe(); + const second = live.subscribe(); + live.setState("connected"); + live.emit(presence); + first.stop(); + assert.equal(live.close.mock.callCount(), 0); + second.stop(); + assert.equal(live.close.mock.callCount(), 1); + assert.equal(live.unsubscribe.mock.callCount(), 1); + assert.equal(live.unsubscribeState.mock.callCount(), 1); + const next = live.subscribe(); + assert.equal(live.connect.mock.callCount(), 2); + assert.deepEqual(next.listener.mock.calls[0].arguments, [null, "connected"]); +}); diff --git a/apps/ui/test/record-draft-model.test.ts b/apps/ui/test/record-draft-model.test.ts index 013028a..0467a9c 100644 --- a/apps/ui/test/record-draft-model.test.ts +++ b/apps/ui/test/record-draft-model.test.ts @@ -5,6 +5,7 @@ import { serializeDraft, upsertRecord, withFormValues, + withRecordSnapshot, type RecordDraft, } from "../src/components/items/record-draft-model"; @@ -62,6 +63,24 @@ test("wire draft preserves nested junction changes but excludes UI data", () => assert.equal(draftChanges(draft), 6); }); +test("accepted record snapshots advance the conflict baseline without losing an initial draft", () => { + const initial: RecordDraft = { + id: "1", + values: {}, + baseValues: { title: "A" }, + }; + const refreshed = withRecordSnapshot(initial, { title: "B" }, false); + assert.deepEqual(refreshed.baseValues, { title: "B" }); + assert.deepEqual( + serializeDraft(withFormValues(refreshed, { title: "C" })).expectedValues, + { title: "B" }, + ); + assert.deepEqual( + withRecordSnapshot(initial, { title: "B" }, true).baseValues, + { title: "A" }, + ); +}); + test("changing reference discards its draft; reverting related edits removes pending work", () => { const draft: RecordDraft = { id: "1", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 47f4bf4..2055803 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -37,6 +37,7 @@ export default defineConfig({ items: [ { text: "Обзор", link: "/" }, { text: "Локальный запуск", link: "/guide/getting-started" }, + { text: "Развёртывание", link: "/guide/deployment" }, { text: "Карта возможностей", link: "/guide/features" }, ], }, @@ -61,7 +62,16 @@ export default defineConfig({ { text: "Разработка", items: [ + { + text: "Локальная разработка", + link: "/development/local-development", + }, { text: "Архитектура", link: "/development/architecture" }, + { text: "Первое расширение", link: "/development/first-extension" }, + { + text: "Архитектура ассистента", + link: "/development/assistant-architecture", + }, { text: "Проверки и документация", link: "/development/documentation", diff --git a/docs/architecture/realtime.md b/docs/architecture/realtime.md new file mode 100644 index 0000000..aa1401e --- /dev/null +++ b/docs/architecture/realtime.md @@ -0,0 +1,39 @@ +# Архитектура Collaborative Live + +SSE выбран для односторонних уведомлений: браузер обращается к Core через `/api`, +а получение и освобождение locks остаётся обычным HTTP-запросом. +Отдельный WebSocket transport не требуется для этого обмена. + +```mermaid +flowchart TD + UI[UI и SDK] -->|SSE с cookie или Bearer| Gateway[Core realtime routes] + Gateway --> Auth[Access и row permissions] + Gateway --> Presence[(asmblyr_presence)] + Gateway --> Locks[(asmblyr_field_locks)] + API[HTTP / Kit writes] --> Domain[Items services] + Domain --> Events[(asmblyr_item_events)] + Events -->|pg_notify после commit| Bus[PostgresRealtimeBus] + Bus --> Gateway +``` + +`recordItemEvent` записывает историю и публикует минимальный envelope внутри +той же транзакции. `LISTEN` использует отдельное соединение; переподключение +слушателя закрывает SSE-потоки, чтобы клиенты перечитали данные. В памяти Core +остаются только подписчики текущего процесса. `InMemoryRealtimeBus` служит для +изолированных тестов; PostgreSQL bus работает без дополнительного Redis. + +Подписка ограничена одним scope (`page`, `collection`, `record`) и одним +clientId. Gateway повторно загружает права перед выдачей события. Коллекция +получает только invalidation, запись — metadata события после проверки строки. +Число соединений ограничено существующим лимитом 32 окон на сессию. +SSE heartbeat не обращается к БД; продление presence и прав выполняется раз в +20 секунд и отправляет клиенту актуальный состав участников. Отставший сокет +закрывается при переполнении буфера. При завершении Core хук `preClose` закрывает +активные SSE-потоки до остановки HTTP-сервера. + +Field locks лежат в Core-owned таблице с составным ключом +`(collection_id, record_id, field)`. `INSERT ... ON CONFLICT ... WHERE` +атомарно разрешает обновление своему editor или перехват истёкшей lease. +Фоновая очистка удаляет просроченные блокировки и публикует `field.unlocked`. +Миграция только добавляет таблицу; откат удаляет временные leases, а не данные +пользователя. diff --git a/docs/assets/brand/README.md b/docs/assets/brand/README.md new file mode 100644 index 0000000..c97495d --- /dev/null +++ b/docs/assets/brand/README.md @@ -0,0 +1,17 @@ +# Визуальная идентичность + +Текущее узнаваемое имя в интерфейсе — **Asmblyr**; полное имя проекта — +**Asmblyr Collaborative**. У проекта есть [публичный сайт](https://asmblyr.io/) +с логотипом и [рабочая консоль](https://console.asmblyr.io/). В README используется +[растровая версия предоставленного логотипа](./asmblyr-wordmark.png) с повышенным +разрешением; исходный векторный файл и снимки продукта пока не хранятся в этом +репозитории. Не заменяйте знак новым символом или скриншотом с пользовательскими +данными. + +Когда исходный SVG с сайта станет доступен, храните его здесь вместе с вариантами для светлого и тёмного +фона; компактный знак должен быть читаем как favicon. Снимки продукта размещайте +в `docs/assets/screenshots`, схемы — в `docs/assets/diagrams`, когда файлы +понадобятся страницам. GitHub social preview следует экспортировать из +официальных исходников и установить в настройках репозитория; сам файл не +назначает preview автоматически. Перед публикацией проверяйте контраст, alt-текст +и отсутствие реальных данных, токенов и внутренних URL. diff --git a/docs/assets/brand/asmblyr-wordmark.png b/docs/assets/brand/asmblyr-wordmark.png new file mode 100644 index 0000000..019b689 Binary files /dev/null and b/docs/assets/brand/asmblyr-wordmark.png differ diff --git a/docs/development/architecture.md b/docs/development/architecture.md index be4c3dd..3f4542c 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -1,7 +1,24 @@ # Архитектура -Asmblyr состоит из HTTP API и отдельного приложения админки. PostgreSQL хранит -структуру, данные и состояние авторизации; S3 хранит содержимое файлов. +Asmblyr состоит из HTTP API и приложения админки. Основная поставка использует +отдельные образы Core и UI; совместимый монолитный образ также доступен. +PostgreSQL хранит структуру, данные и состояние авторизации; S3 хранит содержимое +файлов. + +```mermaid +flowchart LR + Browser[Браузер] -->|страницы| UI[Next.js UI] + Browser -->|/api| Core[Fastify Core API] + UI -->|серверный рендеринг| Core + Client[Внешний клиент / SDK] --> Core + Core --> PG[(PostgreSQL)] + Core --> Objects[(S3-совместимое хранилище)] +``` + +Браузер обращается к Core напрямую через `/api`; Core ведёт браузерную сессию. +Внешний клиент обращается к Core напрямую с собственным access token. +Файлы проходят через Core: метаданные находятся в PostgreSQL, содержимое — +в настроенном объектном хранилище. ## Core и UI @@ -33,10 +50,26 @@ Core 3001, Next 3002. В Helm этот listener выключен: ingress обр ## Контракты и расширения `packages/contracts` содержит общие типы. `packages/sdk` предоставляет HTTP-клиент, -`packages/kit` — контексты, декларации и сборщик расширений. Встроенные комментарии -находятся в `packages/plugin-comments`; опциональные примеры — в `examples/plugins`. +`packages/cli` подключает SDK и генерирует типы, `packages/kit` содержит контексты, +декларации и сборщик расширений. Встроенные комментарии находятся в +`packages/plugin-comments`; инструменты Google — в `packages/plugin-google-workspace`. +Учебные примеры — в `examples/plugins`. Установленные пакеты включаются явно в `asmblyr.plugins` корневого manifest. +```mermaid +flowchart LR + Package[Пакет расширения / Kit] --> Routes[H3 file routes и handlers] + Routes --> Core[Core: HTTP и права] + Routes --> Context[defineModelContext] + Context --> MCP[Внутренний MCP] + MCP --> Assistant[Ассистент] +``` + +`defineModelContext` добавляется только к нужному handler: остальные маршруты +остаются HTTP-only. Ассистент и внутренний MCP используют тот же обработчик; +доступ к данным всё равно проверяет Core. [Пошаговый пример](./first-extension.md) +и [архитектура ассистента](./assistant-architecture.md) раскрывают этот путь. + Серверные обработчики расширений используют H3 file routing. UI импортирует отдельный браузерный entry. Объявленные model handlers используются HTTP, ассистентом и внутренним MCP с общими проверками. Таблицы расширений имеют префикс diff --git a/docs/development/assistant-architecture.md b/docs/development/assistant-architecture.md new file mode 100644 index 0000000..d4ae24e --- /dev/null +++ b/docs/development/assistant-architecture.md @@ -0,0 +1,48 @@ +# Архитектура ассистента и model context + +Ассистент — опциональная функция Core. Ключ и маршрут OpenAI-совместимого +провайдера остаются на сервере; UI передаёт текущую страницу и показывает потоковый +ответ. Поведение и лимиты описаны на [странице функции](../features/assistant.md). + +```mermaid +flowchart LR + UI[UI: чат и контекст страницы] --> Core[Core: запрос и поток ответа] + Core --> Provider[Настроенный модельный провайдер] + Core --> MCP[Внутренний MCP: инструменты] + MCP --> Items[Сервисы items и permissions] + MCP --> Model[Model handlers расширений] + Model --> Items + Items --> DB[(PostgreSQL)] +``` + +## Инструменты и данные + +Встроенные инструменты исследуют разрешённую схему, читают/ищут записи и готовят +фильтры и формы. Часть контекста поступает из display metadata и системного +справочника терминов. Технические имена остаются контрактом API; словарь терминов +помогает сопоставить выражения пользователя с данными, но не меняет права. +Точные сценарии и ограничения перечислены в [функциональном описании](../features/assistant.md). + +Чат требует человеческого пользователя: superuser, хотя бы один data grant +read/create/update либо активное личное Google-подключение. +Каждый вызов инструмента проходит серверную проверку. `items` применяет права +действий, полей и строк; при MCP-вызове учитывается также `mcp.enabled` у каждой +затронутой коллекции, включая связанные пути и superuser. Публичного внешнего MCP +endpoint сейчас нет. Данные, возвращённые инструментом, могут попасть к +настроенному модельному провайдеру. + +## Действия расширений + +`server/api/**/*.post.ts` — обычный H3 HTTP handler. Разработчик явно оборачивает +подходящий обработчик в `defineModelContext(defineHandler(...), annotation)`. +`defineModelAnnotation` задаёт название, описание, `AccessGate`, при необходимости +`page` и `readOnly`. Сборщик Kit выводит runtime-схемы входа и выхода из типов +TypeScript. Один обработчик и его middleware вызываются по HTTP, из ассистента +и внутреннего MCP; ручного реестра в `plugin.ts` нет. + +`AccessGate.authenticated` допускает запрос, но не выдаёт доступ к коллекциям. +`useItems(event)` использует права вызывающего. Для `readOnly: true` запись через +items блокируется. Привилегированное `storage.own` model handler не получает. +Подробный контракт, валидация и ограничения — в +[действиях расширений](./plugin-actions.md); рабочий пример — +[calculator](https://github.com/Asmblyr/Collaborative/tree/main/examples/plugins/calculator). diff --git a/docs/development/first-extension.md b/docs/development/first-extension.md new file mode 100644 index 0000000..76d11cc --- /dev/null +++ b/docs/development/first-extension.md @@ -0,0 +1,141 @@ +# Первое расширение + +Этот путь использует работающие примеры [overview](https://github.com/Asmblyr/Collaborative/tree/main/examples/plugins/overview) +(страница и HTTP endpoint) и [calculator](https://github.com/Asmblyr/Collaborative/tree/main/examples/plugins/calculator) +(действие для ассистента). Примеры не включены в обычную установку. +Начните с [локального запуска](../guide/getting-started.md). + +## 1. Создайте пакет + +Для собственного workspace-пакета, например `@asmblyr-collaborative/plugin-hello`, используйте +`examples/plugins/hello`: +`pnpm-workspace.yaml` включает этот каталог. Удобная отправная точка — исходники +`examples/plugins/overview`, без копирования `dist` и `.asmblyr`. Переименуйте +`name` в `package.json` и `asmblyr.manifest.namespace`; для нового namespace +переименуйте первый каталог в `server/api/`. Namespace должен быть +уникальным и совпадать с первым сегментом HTTP-пути. Сохраните exports для +корневого entry, `./routes`, `./collections`, `./package.json` и `./ui`, script +`"build": "asmblyr-plugin build"`, TypeScript-конфигурацию и зависимости Kit/H3. +Версия манифеста сейчас `1`. [Пакет overview](https://github.com/Asmblyr/Collaborative/blob/main/examples/plugins/overview/package.json) +показывает полный рабочий manifest. + +Обязательный `plugin.ts` минимален: + +```ts +import { definePlugin } from "@asmblyr-collaborative/kit"; + +export default definePlugin({}); +``` + +Маршруты, страницы и model handlers здесь вручную не регистрируются. + +## 2. Добавьте серверный обработчик + +После переименования namespace файл `server/api/hello/session.get.ts` определяет +`GET /hello/session`. Сохраните тело обработчика overview и общий тип ответа: + +```ts +import { defineHandler, useAsmblyr } from "@asmblyr-collaborative/kit"; +import type { Overview } from "../../../shared/overview.js"; + +export default defineHandler((event): Overview => { + const { actor } = useAsmblyr(event); + return { + viewer: { + id: actor.id, + kind: actor.kind, + displayName: actor.displayName ?? null, + }, + checkedAt: new Date().toISOString(), + }; +}); +``` + +Это адаптация [обработчика overview](https://github.com/Asmblyr/Collaborative/blob/main/examples/plugins/overview/server/api/overview/session.get.ts). +Core требует аутентификацию до вызова обработчика. Для записей используйте +`useAsmblyr(event).items` или `useItems(event)`: методы применяют права вызывающего. +`actor.displayName` требует заявленной capability `identity.profile` и +отдельного одобрения проекта. Одно лишь имя в manifest доступа не даёт. +Обработчики с записью проверяют предметные права и валидируют вход. + +## 3. Добавьте страницу + +`ui/index.ts` — отдельный браузерный entry; он не импортирует `plugin.ts` или +серверные файлы: + +```ts +"use client"; + +import { defineUiPlugin } from "@asmblyr-collaborative/kit/ui"; +import { OverviewPage } from "./pages/overview-page.tsx"; + +export default defineUiPlugin({ + pages: [{ id: "home", title: "Обзор", component: OverviewPage }], +}); +``` + +В компоненте `request("/session")` из `PluginPageProps` вызывает namespace endpoint +через BFF и текущую сессию. Рабочие компонент и hook есть в +[overview](https://github.com/Asmblyr/Collaborative/tree/main/examples/plugins/overview/ui). +После подключения страница появляется по адресу +`/extensions/hello/home`. Отдельных прав на UI-страницу пока нет: +серверный endpoint сам должен проверять доступ к своим данным. Для вкладок записи +и редакторов полей смотрите [Kit UI](../reference/kit-ui.md) и +[поля](../reference/kit-fields.md). + +## 4. Подключите пакет и права + +Добавьте пакет в корневые `dependencies` или `devDependencies` через +`"@asmblyr-collaborative/plugin-hello": "workspace:*"`, выполните `pnpm install` и включите +его в корневом `package.json`: + +```json +{ + "asmblyr": { + "plugins": ["@asmblyr-collaborative/plugin-hello"], + "pluginPermissions": { + "@asmblyr-collaborative/plugin-hello": ["identity.profile"] + } + } +} +``` + +Это **фрагмент**, не замена существующего блока: сохраните встроенные +`plugin-comments` и `plugin-google-workspace` с их разрешениями. Скопированный +manifest overview уже заявляет `identity.profile`; +в примере выше проект отдельно одобряет её для нового пакета. Отсутствующее +одобрение означает отсутствие +возможности. Серверные пакеты выполняются в процессе Core как доверенный код; +capabilities не создают песочницу. [Точный контракт](../reference/kit-capabilities.md). + +## 5. Опубликуйте действие для ассистента, если нужно + +Обычный H3 endpoint доступен по HTTP, но не появляется во внутреннем MCP. +Для model handler используйте `defineModelContext` вокруг того же +`defineHandler`, задайте `defineModelAnnotation({ title, description, +middleware: AccessGate.authenticated, readOnly: true })`. Модельный обработчик +должен быть статическим `.post.ts` маршрутом. Kit выведет схемы входа/выхода из +TypeScript; неподдерживаемые типы остановят сборку. Рабочий код с UI-формой — +[calculator](https://github.com/Asmblyr/Collaborative/blob/main/examples/plugins/calculator/server/api/calculator/calculate.post.ts). +Права на данные `AccessGate` не заменяет; внутри используйте `useItems(event)`. +Принцип вызова и ограничения описаны в [архитектуре ассистента](./assistant-architecture.md) +и [контракте действий](./plugin-actions.md). + +## 6. Проверьте и соберите + +```sh +pnpm --filter @asmblyr-collaborative/plugin-hello build +pnpm --filter @asmblyr-collaborative/plugin-hello typecheck +pnpm dev +``` + +Имя фильтра замените реальным именем пакета. Перезапустите Core и UI после +изменения списка плагинов. Войдите в админку, откройте +`/extensions/hello/home` и проверьте запрос страницы. Для отдельного +примерного набора из репозитория доступны `pnpm build:examples` и +`node scripts/test.mjs core-plugins` с одноразовой БД. + +Перед выпуском запускайте `pnpm build`: production loader читает собранный +`dist/routes.json`, а UI — отдельный `./ui` entry. `dist` и `.asmblyr` не включайте +в Git. Собственные коллекции, миграции, hooks и настройки разбираются в +[Kit lifecycle](../reference/kit-lifecycle.md) и [Kit hooks](../reference/kit-hooks.md). diff --git a/docs/development/local-development.md b/docs/development/local-development.md new file mode 100644 index 0000000..f1aed70 --- /dev/null +++ b/docs/development/local-development.md @@ -0,0 +1,54 @@ +# Локальная разработка + +## Структура репозитория + +| Каталог | Ответственность | +| ---------------------------------- | ----------------------------------------------------------- | +| `apps/core` | Fastify API, доменные сервисы, Knex, миграции и авторизация | +| `apps/ui` | Next.js админка и серверный BFF для сессии | +| `packages/contracts` | Общие публичные типы | +| `packages/sdk` | Типизированный HTTP-клиент | +| `packages/cli` | Подключение SDK и генерация типов | +| `packages/kit` | Серверный и браузерный контракт расширений, сборщик | +| `packages/plugin-comments` | Установленный по умолчанию плагин комментариев | +| `packages/plugin-google-workspace` | Инструменты личных Google-подключений | +| `examples/plugins` | Не включённые по умолчанию примеры | +| `deploy`, `docs`, `scripts` | Развёртывание, документация и проверки | + +[Схема компонентов](./architecture.md) показывает путь запросов и данных. + +## Запуск и изменения + +Требуются Node.js 22+, pnpm 11.13.1 и Docker Compose. Выполните +[первый запуск](../guide/getting-started.md), затем работайте через `pnpm dev`. +Эта команда собирает пакеты и запускает Core с UI. Маршруты расширений в локальных +workspace-пакетах отслеживает Core; изменение списка установленных пакетов требует +перезапуска Core и UI. Для production UI и пакеты собираются заново. + +Структуру Core меняйте новой версионной миграцией в `apps/core/migrations`. +Пользовательские коллекции живут в схеме `public`; имена `asmblyr_` и `plugin_` +зарезервированы. Миграции существующей установки выполняются через `pnpm db:migrate`. +Доступ к данным, правам и файлам проверяйте в сервисах Core, а не только в UI. +[Политики и границы доступа](../features/access.md) и +[матрица доступа](../security/access-matrix.md) описывают текущие правила. + +## Проверки + +```sh +pnpm format:check +pnpm typecheck +pnpm lint +pnpm docs:build +pnpm build +``` + +Для интеграционных тестов используйте **только** `node scripts/test.mjs` и +одноразовую БД: скрипту нужен локальный PostgreSQL и `TEST_DATABASE_ADMIN_URL` +с правом `CREATEDB`. `pnpm check` объединяет публикационную проверку, формат, +типы, lint, тесты, документацию; полный набор может быть долгим. +Инструкции по обновлению сгенерированных справочников — в +[сопровождении документации](./documentation.md). + +Перед pull request прочитайте [CONTRIBUTING.md](https://github.com/Asmblyr/Collaborative/blob/main/CONTRIBUTING.md). +Рабочие health/ready проверки, журналы и резервные копии описаны в +[эксплуатации](./operations.md). diff --git a/docs/features/assistant.md b/docs/features/assistant.md index e02ecc4..6b0163a 100644 --- a/docs/features/assistant.md +++ b/docs/features/assistant.md @@ -1,5 +1,8 @@ # Ассистент и внутренний MCP +Путь запроса, инструментов и модельных обработчиков показан в +[архитектуре ассистента](../development/assistant-architecture.md). + Ассистент опционален: Core подключается к OpenAI-совместимому провайдеру, ключ хранится на сервере. UI передаёт контекст текущей страницы; ответы передаются потоком. Переход на предложенную страницу выполняет пользователь кнопкой. Инструменты могут исследовать доступную схему, искать/читать данные, формировать фильтры и выборки, подготавливать формы и вызывать явно объявленные model-handlers расширений. Названия для человека берутся из display metadata; технические имена используются для API. diff --git a/docs/features/plugins.md b/docs/features/plugins.md index bd28d18..8f1191b 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -1,5 +1,7 @@ # Расширения +Если вы пишете первое расширение, начните с [пошагового руководства](../development/first-extension.md). + Плагин — npm/workspace-пакет. Рабочие пакеты проекта находятся в `packages`, демонстрационные — в `examples/plugins`. Обнаружение идёт по списку пакетов `asmblyr.plugins` в корневом `package.json`, а не по имени каталога. Подключённый пакет должен быть установлен. Установка из UI пока не реализована. В разделе «Настройки → Расширения» показывается список плагинов. Нажатие на строку или название открывает штатный правый редактор с настройками выбранного плагина и разрешёнными возможностями. Закрытие несохранённого черновика требует подтверждения; во время сохранения редактор блокирует закрытие. Плагины без параметров также открываются для просмотра возможностей. diff --git a/docs/features/realtime.md b/docs/features/realtime.md new file mode 100644 index 0000000..eb9e767 --- /dev/null +++ b/docs/features/realtime.md @@ -0,0 +1,72 @@ +# Real-time collaboration + +Collaborative Live работает для коллекций и записей, доступных текущему +пользователю. Таблица подписывается на коллекцию и обновляется после сохранения +записи. Карточка показывает участников, состояние соединения, изменения и +редактируемые поля. В клиентском интерфейсе данные перечитываются через обычный +HTTP API: событие содержит только идентификатор, имена разрешённых изменённых +полей и номер события истории. Несохранённый ввод остаётся в редакторе. + +## Presence + +SSE-подписка открывает 30-секундную lease присутствия. Core обновляет её пока +соединение активно, удаляет при закрытии и проверяет право чтения при +подключении и при каждом событии. Участники одного человека объединяются; +отображаются имя, аватар и число окон. После аварийного закрытия остаток lease +исчезает не позднее 30 секунд. Ранее доступный HTTP presence API сохранён. +Каждое продление активной SSE-подписки отправляет актуальный состав участников; +так исчезают и окна, чья lease истекла без события выхода. +Компоненты админки разделяют один поток на scope. Новый подписчик сразу получает +текущее состояние соединения и последний состав участников. При обрыве этот +снимок сбрасывается до получения актуальных данных после переподключения. + +## Live updates и разрешения + +Core публикует `record.created`, `record.updated`, `record.deleted` из +транзакции записи. PostgreSQL доставляет уведомление только после commit и между +экземплярами Core. Подписка на запись проверяет read коллекции и строки по тем +же правилам, что HTTP `/items`. Имена полей фильтруются по фактически видимым +полям строки; sensitive поля не объявляются. Подписка на коллекцию получает +только `collection.changed`, если изменённая строка доступна читателю. При +условных правах удаление не объявляется, поскольку удалённую строку уже нельзя +проверить. Изменение прав обнаруживается при следующем событии или продлении +lease (до 20 секунд), после чего поток закрывается. + +## Field locks и конфликты + +Фокус в редактируемом поле получает lease на 30 секунд. Core проверяет read и +update поля и строки. Вторая сессия получает `409 FIELD_LOCKED`; блокировка +одного поля не мешает редактировать другие. При выходе из поля lease удаляется, +а при обрыве сети истекает автоматически. Клиент повторяет запрос каждые 10 +секунд, пока поле активно. + +Блокировка помогает людям видеть редактирование, но не гарантирует сохранение. +Атомарный draft commit по-прежнему сравнивает `expectedValues` изменяемых полей +и возвращает `409 ITEM_CHANGED`, когда чужая правка затрагивает то же поле. +Редактор оставляет черновик и показывает существующий выбор значений. Обычный +`PATCH` без precondition сохраняет прежний контракт. +Чистая форма принимает новое состояние записи только если к моменту ответа HTTP +у пользователя всё ещё нет несохранённых правок. Вместе с полями обновляется +база `expectedValues`; если ввод начался во время запроса, ответ не заменяет форму. + +## Reconnect и SDK + +SDK `client.realtime.connect()` возвращает типизированное соединение. +`subscribe(scope, callback)` возвращает функцию отписки; `on(type, callback)` +и `onState(callback)` возвращают очистку слушателя. `locks.acquire`, `refresh` +и `release` работают с `{ collection, recordId, field, clientId }`. +При обрыве SDK переподключается с backoff и jitter, повторно регистрирует +presence и посылает `collection.changed`, чтобы клиент перечитал данные. +Это событие получают и callback подписки, и обработчики `on("collection.changed")`. +Повторные ID в одном потоке пропускаются. Отписка и `close()` освобождают +соединение. Браузерная админка открывает `/api/realtime/stream` напрямую в Core +с HttpOnly cookie; прямому клиенту Core нужен действующий пользовательский Bearer token. + +## Границы + +Поток передаёт события операций через Core, включая Kit, но прямой SQL в +пользовательские таблицы не создаёт событий. Комментарии и изменения прав пока +не имеют собственных live-событий. SSE не хранит очередь событий: после +переподключения клиент перечитывает текущие данные. Presence и locks не +поддерживают сервисные ключи. Workspace в envelope равен `null`, поскольку +рабочие пространства группируют коллекции и не изолируют данные. diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md new file mode 100644 index 0000000..4e2c127 --- /dev/null +++ b/docs/guide/deployment.md @@ -0,0 +1,64 @@ +# Развёртывание и обновление + +Для локальной разработки начните с [первого запуска](./getting-started.md). +Основная поставка использует отдельные образы Core и UI из одного коммита; +PostgreSQL 17+ и S3-совместимое хранилище подключаются отдельно. Готовые сборки +публикуются в `ghcr.io/asmblyr/collaborative-core` и +`ghcr.io/asmblyr/collaborative-ui`; закрепляйте оба образа одной версии по digest. +Также доступна [рабочая консоль](https://console.asmblyr.io/). +Одна собственная установка предназначена для одной команды; workspace не создаёт +отдельный tenant. + +| Сценарий | Где начать | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Основная поставка с внешними PostgreSQL/S3 | [`deploy/compose.yaml`](https://github.com/Asmblyr/Collaborative/blob/main/deploy/compose.yaml) и [`deploy/env.example`](https://github.com/Asmblyr/Collaborative/blob/main/deploy/env.example) | +| Локальный стенд с PostgreSQL и MinIO | [`deploy/local/compose.yaml`](https://github.com/Asmblyr/Collaborative/blob/main/deploy/local/compose.yaml) | +| Совместимый монолитный образ | [`deploy/compose.monolith.yaml`](https://github.com/Asmblyr/Collaborative/blob/main/deploy/compose.monolith.yaml) | + +Точные команды сборки и запуска, настройки портов, GHCR и контейнерные проверки — +в [руководстве `deploy`](https://github.com/Asmblyr/Collaborative/blob/main/deploy/README.md). +Основной Compose выполняет миграции отдельным коротким запуском образа Core +перед стартом приложения. Он публикует общий адрес на loopback-порту 3000 +по умолчанию. Не переносите локальные секреты и HTTP origin в публичную +установку. + +## Конфигурация + +Для основной поставки скопируйте `deploy/env.example` в приватный файл вне +репозитория. Core читает `DATABASE_URL` и остальные параметры из окружения; +полный шаблон с комментариями — +[`apps/core/.env.example`](https://github.com/Asmblyr/Collaborative/blob/main/apps/core/.env.example). +Основные группы настроек: + +- `ASMBLYR_SETUP_TOKEN` нужен до создания первого superuser. +- `AUTH_UI_URL` задаёт точный внешний origin UI и обязателен в production. +- `SECRETS_LOCAL_KEY` защищает сохраняемые секреты интеграций. +- S3 можно настроить в системных настройках или через `FILES_STORAGE`, `FILES_BUCKET` + и параметры выбранного провайдера. Bucket создаёт оператор. +- `OPENAI_API_KEY` и `OPENAI_API_MODEL` включают опционального ассистента, + если `ASSISTANT_ENABLED` не установлен в `false`. +- `OAUTH_ISSUER_URL` и `OAUTH_KEYS_FILE` относятся к Asmblyr как OAuth provider; + ключи создаются отдельно и сохраняются между обновлениями. + +Значения и ограничения описаны в env-шаблонах и на страницах +[интеграций](../features/integrations.md), [файлов](../features/files.md) и +[ассистента](../features/assistant.md). Секреты и ключи держите вне Git и образов. + +## Публичная установка + +Разместите UI и Core API за HTTPS reverse proxy. PostgreSQL и bucket должны +оставаться приватными; основной Compose публикует порты только на loopback. +Оператор настраивает TLS, IAM, резервные копии и мониторинг. `GET /health` +проверяет процесс, `GET /ready` — БД и обязательные миграции. Скрипты +резервирования локального стенда не обслуживают внешний облачный bucket. + +## Обновление и восстановление + +Перед обновлением сохраните согласованную копию PostgreSQL и объектов, проверьте +восстановление на отдельной установке и выполните миграции **до запуска нового Core**. +Старые миграции не редактируются; `db:rollback` может удалить данные и не заменяет +восстановление из копии. Откат образа возможен только при совместимой схеме. +Для дополнительного локального стенда доступны +[команды backup/restore и проверка статуса](../development/operations.md#копии-локальной-docker-установки). + +Рекомендации по защите установки — в [границах безопасности](../security/overview.md). diff --git a/docs/guide/features.md b/docs/guide/features.md index 29f89ac..bb44af0 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -5,7 +5,7 @@ | Область | Реализовано | Ограничения | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Коллекции | Создание, single/multiple, тип ключа, поля, подписи, формы, индексы, поиск, системные даты и состояние | Подключение произвольных существующих таблиц/views; перенос между БД как продукт | -| Записи | Таблица, страницы, сортировка, колонки, боковой редактор, адрес записи, пакетные действия, атомарный draft | Нет блокировок полей и доставки правок в реальном времени | +| Записи | Таблица, страницы, сортировка, колонки, боковой редактор, адрес записи, пакетные действия, атомарный draft, live invalidation | Внешний SQL не создаёт live-события | | Связи | M:1, обратные 1:M, M:N через junction, выбор/создание/отвязка, поля связи | Нет обещания произвольной глубины вложенных запросов | | Фильтры | Группы AND/OR, типовые условия, поля связей, сохранённые виды | Условия permissions: прямые поля, И/ИЛИ и параметры участника запроса | | Права | Действия/поля/условия коллекции, переиспользуемые permissions, политики M:N, просмотр настроек и ограниченное назначение готовых политик | DDL остаётся у superuser; per-object file ACL | @@ -15,8 +15,8 @@ | Пространства | Переключатель, набор коллекций, пользовательский выбор | Не являются tenant isolation | | Ассистент | Контекст, stream, инструменты, model-handlers, telemetry, общие лимиты и опциональный retention | Денежный бюджет провайдера; доказанная устойчивость к prompt injection | | Плагины | pnpm/local packages, H3 file routes, типизированный Kit, миграции, hooks, UI/fields/settings, MCP annotations | Песочница, marketplace/install из UI, гарантированная доставка внешних событий | -| SDK | HTTP items list/get/create/update/delete/commit, users.me и presence touch/leave | Обёртки остальных системных endpoints, генерация схем коллекций | -| Коллаборация | Защита изменённых полей от перезаписи, выбор значений при конфликте, участники страниц и карточек | Блокировки активных полей и доставка правок в реальном времени | -| Протоколы | HTTP; OIDC протокол отдельно | Общий GraphQL API | +| SDK | HTTP items list/get/create/update/delete/commit, users.me, presence touch/leave и realtime stream/locks | Обёртки остальных системных endpoints, генерация схем коллекций | +| Коллаборация | Проверка исходных значений, выбор при конфликте, SSE presence, live updates, TTL-блокировки полей | Нет синхронизации комментариев и прав через live protocol | +| Протоколы | HTTP и SSE; OIDC протокол отдельно | Общий GraphQL API | Подробности — на функциональных страницах, требования допуска — в [матрице](../security/access-matrix.md). diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 457b5b4..3c4b5d0 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -2,10 +2,13 @@ Нужны Node.js 22+, pnpm 11.13.1, Docker Compose. Команды выполняются из корня репозитория. +На Unix shell используйте `cp -n apps/core/.env.example apps/core/.env` вместо +`Copy-Item` ниже. Существующий файл окружения сохраняйте. + ```powershell pnpm install --frozen-lockfile pnpm db:up -Copy-Item apps/core/.env.example apps/core/.env +if (!(Test-Path apps/core/.env)) { Copy-Item apps/core/.env.example apps/core/.env } pnpm db:migrate pnpm dev ``` @@ -21,10 +24,13 @@ pnpm dev В `/setup` введите setup secret, email и пароль от 12 символов. Создаётся первый superuser. После появления пользователя endpoint закрывается; секрет можно удалить из конфигурации. Публичной регистрации по паролю нет. -`pnpm dev` предварительно собирает SDK, Kit и встроенные комментарии. `CORE_URL` в серверной конфигурации UI по умолчанию указывает на локальный Core. `GET /health` проверяет процесс, `/ready` — БД и обязательные миграции. +`pnpm dev` предварительно собирает SDK, Kit и встроенные плагины. `CORE_URL` в серверной конфигурации UI по умолчанию указывает на локальный Core. `GET /health` проверяет процесс, `/ready` — БД и обязательные миграции. `pnpm db:down` останавливает PostgreSQL без удаления volume. Не добавляйте `-v`, если данные нужно сохранить. Перед обновлением существующего проекта выполняйте `pnpm db:migrate`; откат DDL может уничтожить данные. +Установка Docker Compose, внешние PostgreSQL/S3 и порядок production-обновлений — +в [руководстве по развёртыванию](./deployment.md). + ## Документация ```powershell diff --git a/docs/index.md b/docs/index.md index a1335c8..b1a34b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,13 +4,15 @@ коллекций, редакторы записей, права команды, файлы и интеграции. Проект находится в ранней бете; одна команда использует отдельную установку. +[Сайт и демо](https://asmblyr.io/) · [Рабочая консоль](https://console.asmblyr.io/) + ## Начать - [Возможности и ограничения](./guide/features.md) - [Первый запуск](./guide/getting-started.md) -- [Docker-установка](https://github.com/Asmblyr/Collaborative/blob/main/deploy/README.md) +- [Docker, production и обновления](./guide/deployment.md) - [HTTP API](./reference/http.md) -- [TypeScript SDK](./reference/sdk-guide.md) +- [TypeScript SDK](./reference/sdk-guide.md) и [CLI](./reference/cli-guide.md) ## Работа с продуктом @@ -20,14 +22,37 @@ [мониторингу](./features/monitoring.md), [интеграциям](./features/integrations.md), [ассистенту](./features/assistant.md), [обсуждениям и уведомлениям](./features/notifications.md), -[локализации](./features/localization.md) и [расширениям](./features/plugins.md) описывают текущие контракты и их границы. +[локализации](./features/localization.md) и [расширениям](./features/plugins.md) +описывают текущие контракты и их границы. +[Личные подключения Google Drive и Sheets](./features/google-workspace.md) описаны отдельно. +[Совместная работа в реальном времени](./features/realtime.md) описывает +присутствие, изменения записей, блокировки полей и ограничения транспорта. + +| Задача | Где читать | +| --------------------------------------------------- | ------------------------------------------------------------- | +| Коллекции, поля, связи, записи, фильтры и поиск | [Данные](./features/data.md) | +| Виды таблицы и рабочие пространства | [Пространства](./features/workspaces.md) | +| Пользователи, вход и сессии | [Авторизация](./features/identity.md) | +| Политики, разрешения и условия строк | [Права](./features/access.md) | +| Файлы и S3 | [Файлы](./features/files.md) | +| Аутентификация API, страницы, ошибки и примеры кода | [HTTP](./reference/http.md) и [SDK](./reference/sdk-guide.md) | ## Разработка и эксплуатация +- [Локальная разработка и проверки](./development/local-development.md) - [Архитектура](./development/architecture.md) +- [Архитектура Collaborative Live](./architecture/realtime.md) +- [Первое расширение](./development/first-extension.md) +- [Архитектура ассистента и model context](./development/assistant-architecture.md) +- [Опубликованные пакеты](./reference/packages.md) - [Сопровождение документации](./development/documentation.md) - [Эксплуатация и восстановление](./development/operations.md) - [Границы безопасности](./security/overview.md) +- [Визуальная идентичность и публичные assets](./assets/brand/README.md) - [Участие в проекте](https://github.com/Asmblyr/Collaborative/blob/main/CONTRIBUTING.md) -Подключение личного Drive и Sheets описано в [Google Workspace](./features/google-workspace.md). +Руководства по Kit и SDK поддерживаются в исходных пакетах и копируются в +`reference` при генерации сайта. Технические детали размещаются рядом с +контрактом; эта главная страница служит маршрутом к ним. Основной язык сейчас +русский; при добавлении других языков следует сохранить общие исходные +контракты и отдельную навигацию для каждого языка. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 925f821..ceefffa 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -6435,6 +6435,10 @@ "message": { "type": "string" }, + "details": { + "type": "object", + "additionalProperties": true + }, "requestId": { "type": "string" } @@ -6442,6 +6446,7 @@ "required": [ "code", "message", + "details", "requestId" ] } @@ -8853,6 +8858,195 @@ "x-source": "apps/core/src/health/readiness.ts" } }, + "/realtime/locks": { + "delete": { + "tags": [ + "realtime" + ], + "operationId": "delete__realtime_locks", + "summary": "DELETE /realtime/locks", + "description": "Активная человеческая сессия; освобождает только собственную lease с совпадающим clientId, даже после потери read/update. Источник: apps/core/src/realtime/routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [], + "responses": { + "204": { + "description": "Собственная lease освобождена или уже отсутствует" + }, + "default": { + "description": "Ошибка проверки, авторизации или исполнения; код HTTP определяет результат." + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/realtime/routes.ts", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "collection": { + "type": "string" + }, + "recordId": { + "type": "string" + }, + "field": { + "type": "string" + }, + "clientId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "collection", + "recordId", + "field", + "clientId" + ] + } + } + } + } + }, + "post": { + "tags": [ + "realtime" + ], + "operationId": "post__realtime_locks", + "summary": "POST /realtime/locks", + "description": "Активная человеческая сессия; read и update указанного поля и строки. Атомарная 30-секундная lease, конфликт 409 FIELD_LOCKED. Источник: apps/core/src/realtime/routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "Получена или продлена lease поля", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "data" + ] + } + } + } + }, + "default": { + "description": "Ошибка проверки, авторизации или исполнения; код HTTP определяет результат." + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/realtime/routes.ts", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "collection": { + "type": "string" + }, + "recordId": { + "type": "string" + }, + "field": { + "type": "string" + }, + "clientId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "collection", + "recordId", + "field", + "clientId" + ] + } + } + } + } + } + }, + "/realtime/stream": { + "get": { + "tags": [ + "realtime" + ], + "operationId": "get__realtime_stream", + "summary": "GET /realtime/stream", + "description": "Активная человеческая сессия через Bearer token или браузерную HttpOnly cookie; scope проверяется теми же collection/row read правилами, что GET /items. Поток повторно проверяет доступ и закрывается при его потере. Presence только для этой сессии. Источник: apps/core/src/realtime/stream.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "clientId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "ID открытого окна" + }, + { + "name": "scope", + "in": "query", + "schema": { + "type": "string", + "maxLength": 1024 + }, + "description": "JSON scope из PresenceInput" + } + ], + "responses": { + "200": { + "description": "SSE envelope; heartbeat-комментарии, событие presence.changed при подключении и изменении присутствия", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "description": "Ошибка проверки, авторизации или исполнения; код HTTP определяет результат." + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/realtime/stream.ts" + } + }, "/schema": { "get": { "tags": [ diff --git a/docs/reference/endpoints.json b/docs/reference/endpoints.json index 98bd5df..ad698cf 100644 --- a/docs/reference/endpoints.json +++ b/docs/reference/endpoints.json @@ -95,6 +95,18 @@ "access": "Активная человеческая сессия; каждый heartbeat проверяет read коллекции/строки или доступ раздела. /files требует files/read либо update у человека, или superuser. Возвращает только ID, имя, аватар и число окон; без email/сессий/значений записи", "public": false }, + "GET /realtime/stream": { + "access": "Активная человеческая сессия через Bearer token или браузерную HttpOnly cookie; scope проверяется теми же collection/row read правилами, что GET /items. Поток повторно проверяет доступ и закрывается при его потере. Presence только для этой сессии", + "public": false + }, + "POST /realtime/locks": { + "access": "Активная человеческая сессия; read и update указанного поля и строки. Атомарная 30-секундная lease, конфликт 409 FIELD_LOCKED", + "public": false + }, + "DELETE /realtime/locks": { + "access": "Активная человеческая сессия; освобождает только собственную lease с совпадающим clientId, даже после потери read/update", + "public": false + }, "DELETE /presence/:clientId": { "access": "Активная человеческая сессия; удаляет только собственное окно этой сессии, без требования сохраняющегося read", "public": false diff --git a/docs/reference/http.md b/docs/reference/http.md index 17b5a46..b42daa9 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -10,6 +10,14 @@ - [Матрица маршрутов и доступа](./routes.md) - [HTTP SDK](./sdk-guide.md) +Для первого запроса и типизированного клиента начните с +[подключения SDK](./sdk-guide.md#подключение-клиента). Там же описаны +[фильтры и пагинация](./sdk-guide.md#реализованные-методы), +[сохранение связей](./sdk-guide.md#запись-и-сохранение-связей) и +[ошибки](./sdk-guide.md#ошибки-и-отмена). Поведение коллекций и пределы поиска +описаны в [данных](../features/data.md), права — в +[матрице доступа](../security/access-matrix.md). + Справочник работает без CDN. Выполнение запросов отключено, чтобы просмотр документации не менял данные. Внешний Swagger validator и сохранение авторизации отключены. ## Полнота @@ -24,4 +32,9 @@ Обычно 400 — некорректный ввод, 401 — нет действительной учётной записи, 403 — недостаточно прав, 404 — ресурс не найден/не раскрывается, 409 — конфликт, 429 — лимит, 503 — зависимость недоступна. Не все endpoints имеют одинаковую обёртку ответа: auth token pair передаётся напрямую, items/users обычно через data. +Ошибки Core имеют единый JSON-формат `{ code, message, details, requestId }`. +`details` сейчас пустой объект; закрытые значения записи и внутренние ошибки в +него не попадают. Сохраняйте `requestId` при диагностике. Прокси UI может +сформировать собственную ошибку, если Core вообще недоступен. + Схема данных коллекции динамическая. Даже успешный create/update может вернуть `data: null` без read grant. Типы больших ID/total остаются строками. Подробный клиентский контракт — в SDK. diff --git a/docs/reference/routes.md b/docs/reference/routes.md index 8fa7583..be31fd3 100644 --- a/docs/reference/routes.md +++ b/docs/reference/routes.md @@ -1,6 +1,6 @@ # Матрица HTTP-маршрутов -Сгенерировано из Core и проверенного каталога доступа. 223 деклараций. +Сгенерировано из Core и проверенного каталога доступа. 226 деклараций. Это описание границ; их исполнение проверяют интеграционные тесты. Динамические маршруты плагинов и внутренние endpoints oidc-provider не перечисляются отдельно. @@ -143,6 +143,9 @@ | `DELETE /presence/:clientId` | Активная человеческая сессия; удаляет только собственное окно этой сессии, без требования сохраняющегося read | `apps/core/src/presence/routes.ts` | | `GET /public/files/:id/content` | Без авторизации: только явно опубликованный ready-файл (visibility=public). Приватный/удалённый файл — 404; no-store/CSP/nosniff. Без списка и метаданных. | `apps/core/src/files/public-routes.ts` | | `GET /ready` | Публичная проверка готовности, без секретов | `apps/core/src/health/readiness.ts` | +| `DELETE /realtime/locks` | Активная человеческая сессия; освобождает только собственную lease с совпадающим clientId, даже после потери read/update | `apps/core/src/realtime/routes.ts` | +| `POST /realtime/locks` | Активная человеческая сессия; read и update указанного поля и строки. Атомарная 30-секундная lease, конфликт 409 FIELD_LOCKED | `apps/core/src/realtime/routes.ts` | +| `GET /realtime/stream` | Активная человеческая сессия через Bearer token или браузерную HttpOnly cookie; scope проверяется теми же collection/row read правилами, что GET /items. Поток повторно проверяет доступ и закрывается при его потере. Presence только для этой сессии | `apps/core/src/realtime/stream.ts` | | `GET /schema` | Активный пользователь или сервисный аккаунт: доступные коллекции, wire-типы и разрешения на поля. Без записей, defaults и условий политик; private/no-store; accepts scoped CLI schema:read access and includes permission-gated generated plugin model contracts | `apps/core/src/schema/routes.ts` | | `GET /search` | Активный principal; действие и поля коллекции, связанные данные и история проверяются отдельно | `apps/core/src/items/routes.ts` | | `GET /service-accounts` | Человек: services/read ИЛИ update; superuser bypass | `apps/core/src/services/routes.ts` | diff --git a/docs/reference/sdk-guide.md b/docs/reference/sdk-guide.md index 85f6707..a93b4f6 100644 --- a/docs/reference/sdk-guide.md +++ b/docs/reference/sdk-guide.md @@ -70,7 +70,7 @@ const profile = await client.users.me(); ``` `baseUrl` — корень API, например `http://localhost:3001` для прямого доступа -к Core или `http://localhost:3000/api` для прокси админки. Префикс `/api` +к Core или `http://localhost:3000/api` для браузерной админки. Префикс `/api` автоматически не добавляется. Путь `/api` без origin работает в браузере. Для прямых запросов к Core передайте access token: @@ -87,7 +87,7 @@ const client = createClient({ каждого запроса. Поддерживаются access tokens пользователя и сервисного аккаунта; права проверяет Core. `/users/me` предназначен для пользователя. SDK не хранит токены в браузере, не выполняет вход и не обновляет токены сам. -В админке обновление сессии остаётся задачей её существующего серверного прокси. +В админке браузерная сессия хранится в HttpOnly cookie, которую проверяет Core. ## Реализованные методы @@ -186,7 +186,8 @@ await client.presence.leave(clientId); `touch` продлевает отметку на 30 секунд и возвращает до 50 пользователей; текущий пользователь идёт первым, окна одного человека объединяются. Повторяйте -вызов, пока окно открыто: админка делает это каждые 5 секунд. SDK сам не создаёт +вызов, пока окно открыто: этот HTTP API требует самостоятельного таймера. +Realtime API ниже обновляет присутствие сам. SDK сам не создаёт таймеры. `leave` удаляет только окно текущей человеческой сессии и безопасен при повторении. До 32 активных окон на одну сессию; превышение возвращает 429. @@ -196,6 +197,42 @@ await client.presence.leave(clientId); может отставать до истечения отметки; присутствие не блокирует запись и не показывает редактируемое поле. +## Collaborative Live + +```ts +const live = client.realtime.connect(); +const unsubscribe = live.subscribe( + { kind: "record", collection: "articles", id: "4" }, + (event) => { + if (event.type === "record.updated") { + // Перечитайте запись через items.get; payload не содержит значения полей. + void client.items.get(event.payload.collection, event.payload.recordId); + } + }, +); +const offState = live.onState((state) => console.log(state)); +const lock = { + collection: "articles", + recordId: "4", + field: "title", + clientId: crypto.randomUUID(), +}; +await live.locks.acquire(lock); +// Пока редактор открыт: await live.locks.refresh(lock) не реже 30 секунд. +await live.locks.release(lock); +unsubscribe(); +offState(); +live.close(); +``` + +`subscribe` работает для page, collection и record. Состояния — `connecting`, +`connected`, `reconnecting`, `offline`. SDK повторяет подписку с backoff и +jitter. После переподключения он отправляет `collection.changed`, чтобы клиент +перечитал данные. Повторные события с тем же ID в потоке пропускаются. Через +админку используется HttpOnly cookie; внешнему API-клиенту нужен пользовательский +Bearer token. Сервисный ключ для realtime не подходит. Блокировки — временный +UX-сигнал, а `items.commit` с `expectedValues` остаётся защитой записи. + ## Типизация коллекций ```ts @@ -254,7 +291,13 @@ try { }); } catch (error) { if (error instanceof ApiError) { - console.error(error.status, error.code, error.requestId, error.message); + console.error( + error.status, + error.code, + error.requestId, + error.message, + error.details, + ); } } ``` @@ -264,7 +307,7 @@ HTTP-ошибка становится `ApiError`. Ошибки сети и от Таймаут по умолчанию — 10 секунд, включая чтение тела ответа; `timeoutMs: 0` отключает его. `request.signal` позволяет отменить конкретный запрос. `request.timeoutMs` переопределяет таймаут одного вызова; редактор админки передаёт -`0` для commit, сохраняя действующий таймаут серверного прокси. +`0` для commit, не ограничивая длительность запроса таймаутом SDK. SDK не повторяет запросы автоматически и отклоняет HTTP-редиректы. Ответы запрашиваются с `cache: "no-store"`. diff --git a/examples/plugins/calculator/README.md b/examples/plugins/calculator/README.md index ad6df24..f22508d 100644 --- a/examples/plugins/calculator/README.md +++ b/examples/plugins/calculator/README.md @@ -29,4 +29,4 @@ MCP и HTTP вызывают один handler; обёртка `defineModelContex Она исчезает после перезапуска Core; новые ручные значения можно пересчитать. Запрос должен содержать все параметры — цены и скидки ассистент не выдумывает. -Контракт, доступ и ограничения: [действия плагинов](../../docs/development/plugin-actions.md). +Контракт, доступ и ограничения: [действия плагинов](../../../docs/development/plugin-actions.md). diff --git a/packages/contracts/locales/ui.en.json b/packages/contracts/locales/ui.en.json index 7accc34..fe9f750 100644 --- a/packages/contracts/locales/ui.en.json +++ b/packages/contracts/locales/ui.en.json @@ -2136,6 +2136,14 @@ "copy.a0e89f48": "System collections", "copy.91fb677a": "Workspaces", "copy.fc4d06c6": "No records", + "copy.baf1dc76": "Another person changed this record. Review any conflict before saving.", + "copy.b16be3b4": "Another person deleted this record.", + "copy.347125e1": "The field lock expired. Review changes before saving.", + "copy.7a5b2a00": "Another person is editing this field.", + "copy.fa37a4df": "Online", + "copy.3b8f3bee": "Reconnecting…", + "copy.ed57e787": "Offline", + "copy.452a0ba5": "{{value0}} is editing", "copy.1075e4cf": "Create a regular collection for the related records first.", "copy.699766bc": "Select one record. Deleting the related record clears this field and preserves the system record.", "copy.b6c0f68c": "Custom user fields", diff --git a/packages/contracts/locales/ui.ru.json b/packages/contracts/locales/ui.ru.json index 03ca3f4..8c69adc 100644 --- a/packages/contracts/locales/ui.ru.json +++ b/packages/contracts/locales/ui.ru.json @@ -2136,6 +2136,14 @@ "copy.a0e89f48": "Системные коллекции", "copy.91fb677a": "Рабочие пространства", "copy.fc4d06c6": "Нет записей", + "copy.baf1dc76": "Запись изменена другим участником. При сохранении проверьте конфликт.", + "copy.b16be3b4": "Запись удалена другим участником.", + "copy.347125e1": "Блокировка поля истекла. Проверьте изменения перед сохранением.", + "copy.7a5b2a00": "Это поле сейчас редактирует другой участник.", + "copy.fa37a4df": "В сети", + "copy.3b8f3bee": "Переподключение…", + "copy.ed57e787": "Нет соединения", + "copy.452a0ba5": "{{value0}} редактирует", "copy.1075e4cf": "Сначала создайте обычную коллекцию для связанных записей.", "copy.699766bc": "Выбор одной записи. Если связанную запись удалить, поле очистится. Системная запись останется.", "copy.b6c0f68c": "Дополнительные поля пользователя", diff --git a/packages/contracts/src/api-error.d.ts b/packages/contracts/src/api-error.d.ts index 60f2134..1b74b27 100644 --- a/packages/contracts/src/api-error.d.ts +++ b/packages/contracts/src/api-error.d.ts @@ -1,5 +1,6 @@ export interface ApiErrorBody { code: string; message: string; + details: Record; requestId: string; } diff --git a/packages/contracts/src/index.d.ts b/packages/contracts/src/index.d.ts index 52d791f..78acd6f 100644 --- a/packages/contracts/src/index.d.ts +++ b/packages/contracts/src/index.d.ts @@ -36,6 +36,14 @@ export { } from "./table-columns.js"; export type { ColumnPreferences } from "./table-columns.js"; export { presencePages } from "./presence.js"; +export type { + RealtimeActor, + RealtimeEnvelope, + RealtimeEvent, + RealtimePayloads, + RealtimeConnectionState, + RealtimeSubscription, +} from "./realtime.js"; export type { PresenceScope, PresenceInput, diff --git a/packages/contracts/src/realtime.d.ts b/packages/contracts/src/realtime.d.ts new file mode 100644 index 0000000..b3dd3d7 --- /dev/null +++ b/packages/contracts/src/realtime.d.ts @@ -0,0 +1,59 @@ +import type { PresenceScope, PresenceParticipant } from "./presence.js"; + +export type RealtimeConnectionState = + | "connecting" + | "connected" + | "reconnecting" + | "offline"; + +export interface RealtimeActor { + id: string; + kind: "user" | "service"; +} + +export interface RealtimeEnvelope { + id: string; + type: T; + timestamp: string; + workspaceId: string | null; + actor: RealtimeActor | null; + payload: P; +} + +export interface RealtimePayloads { + "presence.changed": { + scope: PresenceScope; + participants: PresenceParticipant[]; + total: number; + }; + "record.created": RecordChangePayload; + "record.updated": RecordChangePayload; + "record.deleted": RecordChangePayload; + "field.locked": FieldLockPayload; + "field.unlocked": FieldLockPayload; + "collection.changed": { collection: string }; +} + +export interface RecordChangePayload { + collection: string; + recordId: string; + changedFields: string[]; + revision: string; +} + +export interface FieldLockPayload { + collection: string; + recordId: string; + field: string; + holder: { id: string; displayName: string }; + expiresAt: string; +} + +export type RealtimeEvent = { + [T in keyof RealtimePayloads]: RealtimeEnvelope; +}[keyof RealtimePayloads]; + +export interface RealtimeSubscription { + clientId: string; + scope: PresenceScope; +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4036d86..6148e69 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -68,7 +68,7 @@ const profile = await client.users.me(); ``` `baseUrl` — корень API, например `http://localhost:3001` для прямого доступа -к Core или `http://localhost:3000/api` для прокси админки. Префикс `/api` +к Core или `http://localhost:3000/api` для браузерной админки. Префикс `/api` автоматически не добавляется. Путь `/api` без origin работает в браузере. Для прямых запросов к Core передайте access token: @@ -85,7 +85,7 @@ const client = createClient({ каждого запроса. Поддерживаются access tokens пользователя и сервисного аккаунта; права проверяет Core. `/users/me` предназначен для пользователя. SDK не хранит токены в браузере, не выполняет вход и не обновляет токены сам. -В админке обновление сессии остаётся задачей её существующего серверного прокси. +В админке браузерная сессия хранится в HttpOnly cookie, которую проверяет Core. ## Реализованные методы @@ -184,7 +184,8 @@ await client.presence.leave(clientId); `touch` продлевает отметку на 30 секунд и возвращает до 50 пользователей; текущий пользователь идёт первым, окна одного человека объединяются. Повторяйте -вызов, пока окно открыто: админка делает это каждые 5 секунд. SDK сам не создаёт +вызов, пока окно открыто: этот HTTP API требует самостоятельного таймера. +Realtime API ниже обновляет присутствие сам. SDK сам не создаёт таймеры. `leave` удаляет только окно текущей человеческой сессии и безопасен при повторении. До 32 активных окон на одну сессию; превышение возвращает 429. @@ -194,6 +195,42 @@ await client.presence.leave(clientId); может отставать до истечения отметки; присутствие не блокирует запись и не показывает редактируемое поле. +## Collaborative Live + +```ts +const live = client.realtime.connect(); +const unsubscribe = live.subscribe( + { kind: "record", collection: "articles", id: "4" }, + (event) => { + if (event.type === "record.updated") { + // Перечитайте запись через items.get; payload не содержит значения полей. + void client.items.get(event.payload.collection, event.payload.recordId); + } + }, +); +const offState = live.onState((state) => console.log(state)); +const lock = { + collection: "articles", + recordId: "4", + field: "title", + clientId: crypto.randomUUID(), +}; +await live.locks.acquire(lock); +// Пока редактор открыт: await live.locks.refresh(lock) не реже 30 секунд. +await live.locks.release(lock); +unsubscribe(); +offState(); +live.close(); +``` + +`subscribe` работает для page, collection и record. Состояния — `connecting`, +`connected`, `reconnecting`, `offline`. SDK повторяет подписку с backoff и +jitter. После переподключения он отправляет `collection.changed`, чтобы клиент +перечитал данные. Повторные события с тем же ID в потоке пропускаются. Через +админку используется HttpOnly cookie; внешнему API-клиенту нужен пользовательский +Bearer token. Сервисный ключ для realtime не подходит. Блокировки — временный +UX-сигнал, а `items.commit` с `expectedValues` остаётся защитой записи. + ## Типизация коллекций ```ts @@ -252,7 +289,13 @@ try { }); } catch (error) { if (error instanceof ApiError) { - console.error(error.status, error.code, error.requestId, error.message); + console.error( + error.status, + error.code, + error.requestId, + error.message, + error.details, + ); } } ``` @@ -262,7 +305,7 @@ HTTP-ошибка становится `ApiError`. Ошибки сети и от Таймаут по умолчанию — 10 секунд, включая чтение тела ответа; `timeoutMs: 0` отключает его. `request.signal` позволяет отменить конкретный запрос. `request.timeoutMs` переопределяет таймаут одного вызова; редактор админки передаёт -`0` для commit, сохраняя действующий таймаут серверного прокси. +`0` для commit, не ограничивая длительность запроса таймаутом SDK. SDK не повторяет запросы автоматически и отклоняет HTTP-редиректы. Ответы запрашиваются с `cache: "no-store"`. diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 9bbb329..96e9533 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -11,6 +11,7 @@ import { import type { ClientOptions, RequestOptions } from "./options.js"; import { createTransport } from "./transport.js"; import { createPresenceClient, type PresenceClient } from "./presence.js"; +import { createRealtimeClient } from "./realtime.js"; import { createNotificationsClient, type NotificationsClient, @@ -24,6 +25,7 @@ export interface AsmblyrClient { readonly items: ItemsClient; readonly users: UsersClient; readonly presence: PresenceClient; + readonly realtime: ReturnType; readonly notifications: NotificationsClient; readonly schema: { pull(request?: RequestOptions): Promise }; readonly translations: { @@ -70,6 +72,7 @@ export function createClient( items, users: createUsersClient(transport), presence: createPresenceClient(transport), + realtime: createRealtimeClient(options, transport), notifications: createNotificationsClient(transport), schema: { pull: (request) => transport.get("/schema", undefined, request) }, translations: { diff --git a/packages/sdk/src/error.ts b/packages/sdk/src/error.ts index 120651c..f32052b 100644 --- a/packages/sdk/src/error.ts +++ b/packages/sdk/src/error.ts @@ -6,6 +6,7 @@ export class ApiError extends Error { readonly status: number, readonly code?: string, readonly requestId?: string, + readonly details?: Record, ) { super(message); this.name = "ApiError"; @@ -22,11 +23,19 @@ export async function responseError(response: Response): Promise { details.code = body.code; if ("requestId" in body && typeof body.requestId === "string") details.requestId = body.requestId; + if ( + "details" in body && + body.details && + typeof body.details === "object" && + !Array.isArray(body.details) + ) + details.details = body.details as Record; } return new ApiError( details.message ?? `API request failed (${response.status})`, response.status, details.code, details.requestId, + details.details, ); } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 20c508a..d39bc19 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,4 +1,5 @@ export { createClient, type AsmblyrClient } from "./client.js"; +export type { RealtimeConnection, RealtimeLock } from "./realtime.js"; export type { UsersClient } from "./users.js"; export { ApiError } from "./error.js"; export type { PluginsClient, PluginPaths } from "./plugins.js"; diff --git a/packages/sdk/src/realtime.ts b/packages/sdk/src/realtime.ts new file mode 100644 index 0000000..0b5b115 --- /dev/null +++ b/packages/sdk/src/realtime.ts @@ -0,0 +1,258 @@ +import type { + PresenceScope, + RealtimeConnectionState, + RealtimeEvent, +} from "@asmblyr-collaborative/contracts"; +import type { ClientOptions } from "./options.js"; +import type { Transport } from "./transport.js"; +import { responseError } from "./error.js"; + +export interface RealtimeLock { + collection: string; + recordId: string; + field: string; + clientId: string; +} + +export interface RealtimeConnection { + readonly state: RealtimeConnectionState; + subscribe( + scope: PresenceScope, + callback: (event: RealtimeEvent) => void, + ): () => void; + on( + type: T, + callback: (event: RealtimeEvent & { type: T }) => void, + ): () => void; + onState(callback: (state: RealtimeConnectionState) => void): () => void; + locks: { + acquire(input: RealtimeLock): Promise; + refresh(input: RealtimeLock): Promise; + release(input: RealtimeLock): Promise; + }; + close(): void; +} + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + signal.addEventListener("abort", done, { once: true }); + }); + +export function createRealtimeClient( + options: ClientOptions, + transport: Transport, +) { + return { + connect(): RealtimeConnection { + let state: RealtimeConnectionState = "connecting"; + let closed = false; + const controllers = new Set(); + const stateListeners = new Set< + (state: RealtimeConnectionState) => void + >(); + const listeners = new Map< + RealtimeEvent["type"], + Set<(event: RealtimeEvent) => void> + >(); + const heldLocks = new Map(); + const fetcher = options.fetch ?? globalThis.fetch; + const root = options.baseUrl.replace(/\/+$/, ""); + const lockKey = (input: RealtimeLock) => JSON.stringify(input); + function setState(next: RealtimeConnectionState) { + if (state === next) return; + state = next; + for (const listener of stateListeners) listener(next); + } + function dispatch( + event: RealtimeEvent, + callback: (event: RealtimeEvent) => void, + recentIds: Set, + ) { + if (recentIds.has(event.id)) return; + recentIds.add(event.id); + if (recentIds.size > 256) + recentIds.delete(recentIds.values().next().value!); + callback(event); + for (const listener of listeners.get(event.type) ?? []) listener(event); + } + async function stream( + scope: PresenceScope, + callback: (event: RealtimeEvent) => void, + controller: AbortController, + ) { + let attempt = 0; + const clientId = crypto.randomUUID(); + const recentIds = new Set(); + while (!controller.signal.aborted && !closed) { + setState(attempt ? "reconnecting" : "connecting"); + let reader: ReadableStreamDefaultReader | null = null; + try { + const token = + typeof options.accessToken === "function" + ? await options.accessToken() + : options.accessToken; + const headers = new Headers(options.headers); + headers.set("accept", "text/event-stream"); + if (token) headers.set("authorization", `Bearer ${token}`); + const query = new URLSearchParams({ + clientId, + scope: JSON.stringify(scope), + }); + const response = await fetcher(`${root}/realtime/stream?${query}`, { + headers, + credentials: options.credentials ?? "same-origin", + cache: "no-store", + signal: controller.signal, + redirect: "error", + }); + if (!response.ok) throw await responseError(response); + if (!response.body) throw new Error("Realtime stream unavailable"); + setState("connected"); + if (attempt) { + // A reconnect may have missed events; callers refetch from the HTTP API. + dispatch( + { + id: crypto.randomUUID(), + type: "collection.changed", + timestamp: new Date().toISOString(), + workspaceId: null, + actor: null, + payload: { + collection: scope.kind === "page" ? "" : scope.collection, + }, + }, + callback, + recentIds, + ); + for (const lock of heldLocks.values()) { + if ( + scope.kind === "record" && + lock.collection === scope.collection && + lock.recordId === scope.id + ) { + await transport + .write("POST", "/realtime/locks", lock) + .catch(() => undefined); + } + } + } + attempt = 0; + reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (!controller.signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + if (buffer.length > 64_000) + throw new Error("Realtime frame too large"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = frame + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .join("\n"); + if (data) + dispatch( + JSON.parse(data) as RealtimeEvent, + callback, + recentIds, + ); + boundary = buffer.indexOf("\n\n"); + } + } + } catch (error) { + if (controller.signal.aborted || closed) break; + if ( + error && + typeof error === "object" && + "status" in error && + (error.status === 401 || + error.status === 403 || + error.status === 404) + ) { + setState("offline"); + break; + } + } finally { + await reader?.cancel().catch(() => undefined); + reader?.releaseLock(); + } + if (controller.signal.aborted || closed) break; + attempt += 1; + setState("reconnecting"); + const backoff = Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)); + await sleep(backoff * (0.5 + Math.random()), controller.signal); + } + controllers.delete(controller); + if (!controllers.size) setState("offline"); + } + return { + get state() { + return state; + }, + subscribe(scope, callback) { + if (closed) throw new Error("Realtime connection closed"); + const controller = new AbortController(); + controllers.add(controller); + void stream(scope, callback, controller); + return () => { + controller.abort(); + controllers.delete(controller); + if (!controllers.size) setState("offline"); + }; + }, + on(type, callback) { + const group = listeners.get(type) ?? new Set(); + group.add(callback as (event: RealtimeEvent) => void); + listeners.set(type, group); + return () => group.delete(callback as (event: RealtimeEvent) => void); + }, + onState(callback) { + stateListeners.add(callback); + callback(state); + return () => stateListeners.delete(callback); + }, + locks: { + async acquire(input) { + const result = await transport.write( + "POST", + "/realtime/locks", + input, + ); + heldLocks.set(lockKey(input), input); + return result; + }, + async refresh(input) { + return transport.write("POST", "/realtime/locks", input); + }, + async release(input) { + heldLocks.delete(lockKey(input)); + await transport.write("DELETE", "/realtime/locks", input); + }, + }, + close() { + closed = true; + for (const controller of controllers) controller.abort(); + controllers.clear(); + for (const lock of heldLocks.values()) { + void transport + .write("DELETE", "/realtime/locks", lock) + .catch(() => undefined); + } + heldLocks.clear(); + setState("offline"); + }, + }; + }, + }; +} diff --git a/packages/sdk/test/client.test.mjs b/packages/sdk/test/client.test.mjs index 9abe782..8048b33 100644 --- a/packages/sdk/test/client.test.mjs +++ b/packages/sdk/test/client.test.mjs @@ -121,7 +121,12 @@ test("API failures retain status, code and requestId without retries", async () fetch: async () => { calls++; return Response.json( - { code: "TEST_ERROR", message: "Denied", requestId: "request-1" }, + { + code: "TEST_ERROR", + message: "Denied", + details: { field: "title" }, + requestId: "request-1", + }, { status }, ); }, @@ -132,6 +137,7 @@ test("API failures retain status, code and requestId without retries", async () assert.equal(error.code, "TEST_ERROR"); assert.equal(error.requestId, "request-1"); assert.equal(error.message, "Denied"); + assert.deepEqual(error.details, { field: "title" }); return true; }); assert.equal(calls, 1); diff --git a/packages/sdk/test/realtime.test.mjs b/packages/sdk/test/realtime.test.mjs new file mode 100644 index 0000000..d24ba72 --- /dev/null +++ b/packages/sdk/test/realtime.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createClient } from "../dist/index.js"; + +function frame(event) { + return `id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`; +} + +test("realtime reconnects, restores scope and ignores duplicate IDs", async (t) => { + const seen = []; + const invalidations = []; + const states = []; + const scope = { kind: "record", collection: "articles", id: "4" }; + const event = { + id: "once", + type: "record.updated", + timestamp: new Date().toISOString(), + workspaceId: null, + actor: { kind: "user", id: "other" }, + payload: { + collection: "articles", + recordId: "4", + changedFields: ["title"], + revision: "5", + }, + }; + let requests = 0; + const client = createClient({ + baseUrl: "https://example.test/api", + accessToken: "token", + fetch: async (url, init) => { + requests += 1; + const parsed = new URL(url); + assert.equal(parsed.pathname, "/api/realtime/stream"); + assert.deepEqual(JSON.parse(parsed.searchParams.get("scope")), scope); + assert.equal(init.headers.get("authorization"), "Bearer token"); + const text = + requests === 1 + ? frame(event) + : frame(event) + frame({ ...event, id: "new" }); + const bytes = new TextEncoder().encode(text); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice(0, 17)); + controller.enqueue(bytes.slice(17)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const live = client.realtime.connect(); + t.after(() => live.close()); + live.on("collection.changed", (event) => invalidations.push(event)); + const offState = live.onState((state) => states.push(state)); + const unsubscribe = live.subscribe(scope, (received) => seen.push(received)); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Reconnect timed out")), + 3000, + ); + const check = setInterval(() => { + if (seen.some((entry) => entry.id === "new")) { + clearInterval(check); + clearTimeout(timeout); + resolve(); + } + }, 10); + }); + unsubscribe(); + offState(); + live.close(); + assert.equal(requests, 2); + assert.equal(seen.filter((entry) => entry.id === "once").length, 1); + assert.ok(seen.some((entry) => entry.type === "collection.changed")); + const scopeInvalidations = seen.filter( + (entry) => entry.type === "collection.changed", + ); + assert.equal(scopeInvalidations.length, 1); + assert.deepEqual(invalidations, scopeInvalidations); + assert.deepEqual(invalidations[0].payload, { collection: "articles" }); + assert.ok(states.includes("reconnecting")); + assert.equal(live.state, "offline"); +}); diff --git a/scripts/docs/http-contracts.mjs b/scripts/docs/http-contracts.mjs index 7613c72..0c4431d 100644 --- a/scripts/docs/http-contracts.mjs +++ b/scripts/docs/http-contracts.mjs @@ -210,13 +210,67 @@ export function operationContract(key) { }, }; } + if (key === "GET /realtime/stream") { + return { + parameters: [ + query( + "clientId", + { type: "string", format: "uuid" }, + "ID открытого окна", + ), + query( + "scope", + { type: "string", maxLength: 1024 }, + "JSON scope из PresenceInput", + ), + ], + responses: { + 200: { + description: + "SSE envelope; heartbeat-комментарии, событие presence.changed при подключении и изменении присутствия", + content: { "text/event-stream": { schema: { type: "string" } } }, + }, + default: error, + }, + }; + } + if (key === "POST /realtime/locks" || key === "DELETE /realtime/locks") { + const lockBody = wrapped({ + collection: string, + recordId: string, + field: string, + clientId: { type: "string", format: "uuid" }, + }); + return { + requestBody: body(lockBody), + responses: key.startsWith("POST") + ? { + 200: response( + wrapped({ data: object }), + "Получена или продлена lease поля", + ), + default: error, + } + : { + 204: { + description: "Собственная lease освобождена или уже отсутствует", + }, + default: error, + }, + }; + } if (key === "POST /items/:collection/commit") { return { requestBody: body(itemCommitBody), responses: { 200: response(wrapped({ data: wrapped({ id: string }) })), 409: response( - wrapped({ code: string, message: string, requestId: string }), + wrapped({ + code: string, + message: string, + details: object, + requestId: string, + }), "Конфликт, включая ITEM_CHANGED. Весь черновик и его история откатываются. Ответ не содержит закрытых значений.", ), default: error, diff --git a/scripts/test.mjs b/scripts/test.mjs index 3cdf2e5..d05daa6 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -254,6 +254,8 @@ async function main() { "test/plugin-translations.test.ts", "test/plugin-build.test.ts", "test/presence.integration.test.ts", + "test/realtime.integration.test.ts", + "test/realtime-bus.test.ts", "test/http-access-surface.integration.test.ts", ].includes(name); } @@ -265,6 +267,7 @@ async function main() { if (suite === "core-presence") { return [ "test/presence.integration.test.ts", + "test/realtime.integration.test.ts", "test/http-access-surface.integration.test.ts", ].includes(name); }