From a39cebe81a977ff81c8cc1c8a8b0362bce1361f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:29:23 +0000 Subject: [PATCH 1/2] Initial plan From fb12cce87d9639e4d6c871f7e5b6e848d28f9f0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:38:37 +0000 Subject: [PATCH 2/2] feat: implement WebSocket and Collaboration protocols - Created websocket.zod.ts with comprehensive WebSocket event protocol - Created collaboration.zod.ts with OT, CRDT, cursor sharing, and awareness - Added comprehensive test coverage for both protocols - Updated exports in index files - All tests passing and build successful Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- content/docs/references/api/index.mdx | 1 + content/docs/references/api/meta.json | 3 +- content/docs/references/api/websocket.mdx | 374 +++++++ .../docs/references/system/collaboration.mdx | 407 +++++++ content/docs/references/system/index.mdx | 1 + content/docs/references/system/meta.json | 1 + packages/spec/json-schema/api/AckMessage.json | 46 + .../spec/json-schema/api/CursorMessage.json | 139 +++ .../spec/json-schema/api/CursorPosition.json | 112 ++ .../spec/json-schema/api/DocumentState.json | 49 + .../spec/json-schema/api/EditMessage.json | 135 +++ .../spec/json-schema/api/EditOperation.json | 108 ++ .../json-schema/api/EditOperationType.json | 14 + .../spec/json-schema/api/ErrorMessage.json | 44 + .../spec/json-schema/api/EventFilter.json | 65 ++ .../json-schema/api/EventFilterCondition.json | 42 + .../spec/json-schema/api/EventMessage.json | 55 + .../spec/json-schema/api/EventPattern.json | 12 + .../json-schema/api/EventSubscription.json | 105 ++ .../spec/json-schema/api/FilterOperator.json | 24 + .../spec/json-schema/api/PingMessage.json | 31 + .../spec/json-schema/api/PongMessage.json | 36 + .../spec/json-schema/api/PresenceMessage.json | 92 ++ .../spec/json-schema/api/PresenceState.json | 65 ++ .../spec/json-schema/api/PresenceUpdate.json | 35 + .../json-schema/api/SubscribeMessage.json | 132 +++ .../json-schema/api/UnsubscribeMessage.json | 47 + .../json-schema/api/UnsubscribeRequest.json | 20 + .../spec/json-schema/api/WebSocketConfig.json | 63 ++ .../json-schema/api/WebSocketMessage.json | 707 +++++++++++++ .../json-schema/api/WebSocketMessageType.json | 21 + .../api/WebSocketPresenceStatus.json | 15 + .../json-schema/system/AwarenessEvent.json | 51 + .../json-schema/system/AwarenessSession.json | 117 ++ .../json-schema/system/AwarenessUpdate.json | 35 + .../system/AwarenessUserState.json | 77 ++ .../json-schema/system/CRDTMergeResult.json | 295 ++++++ .../spec/json-schema/system/CRDTState.json | 258 +++++ .../spec/json-schema/system/CRDTType.json | 20 + .../json-schema/system/CollaborationMode.json | 15 + .../system/CollaborationSession.json | 575 ++++++++++ .../system/CollaborationSessionConfig.json | 86 ++ .../system/CollaborativeCursor.json | 189 ++++ .../json-schema/system/CounterOperation.json | 30 + .../json-schema/system/CursorColorPreset.json | 21 + .../json-schema/system/CursorSelection.json | 66 ++ .../spec/json-schema/system/CursorStyle.json | 59 ++ .../spec/json-schema/system/CursorUpdate.json | 101 ++ .../spec/json-schema/system/GCounter.json | 28 + .../spec/json-schema/system/LWWRegister.json | 51 + packages/spec/json-schema/system/ORSet.json | 57 + .../spec/json-schema/system/ORSetElement.json | 39 + .../spec/json-schema/system/OTComponent.json | 76 ++ .../spec/json-schema/system/OTOperation.json | 128 +++ .../json-schema/system/OTOperationType.json | 14 + .../json-schema/system/OTTransformResult.json | 150 +++ .../spec/json-schema/system/PNCounter.json | 37 + .../json-schema/system/TextCRDTOperation.json | 52 + .../json-schema/system/TextCRDTState.json | 105 ++ .../system/UserActivityStatus.json | 15 + .../spec/json-schema/system/VectorClock.json | 23 + packages/spec/src/api/index.ts | 1 + packages/spec/src/api/websocket.test.ts | 829 +++++++++++++++ packages/spec/src/api/websocket.zod.ts | 433 ++++++++ .../spec/src/system/collaboration.test.ts | 999 ++++++++++++++++++ packages/spec/src/system/collaboration.zod.ts | 482 +++++++++ packages/spec/src/system/index.ts | 1 + 67 files changed, 8515 insertions(+), 1 deletion(-) create mode 100644 content/docs/references/api/websocket.mdx create mode 100644 content/docs/references/system/collaboration.mdx create mode 100644 packages/spec/json-schema/api/AckMessage.json create mode 100644 packages/spec/json-schema/api/CursorMessage.json create mode 100644 packages/spec/json-schema/api/CursorPosition.json create mode 100644 packages/spec/json-schema/api/DocumentState.json create mode 100644 packages/spec/json-schema/api/EditMessage.json create mode 100644 packages/spec/json-schema/api/EditOperation.json create mode 100644 packages/spec/json-schema/api/EditOperationType.json create mode 100644 packages/spec/json-schema/api/ErrorMessage.json create mode 100644 packages/spec/json-schema/api/EventFilter.json create mode 100644 packages/spec/json-schema/api/EventFilterCondition.json create mode 100644 packages/spec/json-schema/api/EventMessage.json create mode 100644 packages/spec/json-schema/api/EventPattern.json create mode 100644 packages/spec/json-schema/api/EventSubscription.json create mode 100644 packages/spec/json-schema/api/FilterOperator.json create mode 100644 packages/spec/json-schema/api/PingMessage.json create mode 100644 packages/spec/json-schema/api/PongMessage.json create mode 100644 packages/spec/json-schema/api/PresenceMessage.json create mode 100644 packages/spec/json-schema/api/PresenceState.json create mode 100644 packages/spec/json-schema/api/PresenceUpdate.json create mode 100644 packages/spec/json-schema/api/SubscribeMessage.json create mode 100644 packages/spec/json-schema/api/UnsubscribeMessage.json create mode 100644 packages/spec/json-schema/api/UnsubscribeRequest.json create mode 100644 packages/spec/json-schema/api/WebSocketConfig.json create mode 100644 packages/spec/json-schema/api/WebSocketMessage.json create mode 100644 packages/spec/json-schema/api/WebSocketMessageType.json create mode 100644 packages/spec/json-schema/api/WebSocketPresenceStatus.json create mode 100644 packages/spec/json-schema/system/AwarenessEvent.json create mode 100644 packages/spec/json-schema/system/AwarenessSession.json create mode 100644 packages/spec/json-schema/system/AwarenessUpdate.json create mode 100644 packages/spec/json-schema/system/AwarenessUserState.json create mode 100644 packages/spec/json-schema/system/CRDTMergeResult.json create mode 100644 packages/spec/json-schema/system/CRDTState.json create mode 100644 packages/spec/json-schema/system/CRDTType.json create mode 100644 packages/spec/json-schema/system/CollaborationMode.json create mode 100644 packages/spec/json-schema/system/CollaborationSession.json create mode 100644 packages/spec/json-schema/system/CollaborationSessionConfig.json create mode 100644 packages/spec/json-schema/system/CollaborativeCursor.json create mode 100644 packages/spec/json-schema/system/CounterOperation.json create mode 100644 packages/spec/json-schema/system/CursorColorPreset.json create mode 100644 packages/spec/json-schema/system/CursorSelection.json create mode 100644 packages/spec/json-schema/system/CursorStyle.json create mode 100644 packages/spec/json-schema/system/CursorUpdate.json create mode 100644 packages/spec/json-schema/system/GCounter.json create mode 100644 packages/spec/json-schema/system/LWWRegister.json create mode 100644 packages/spec/json-schema/system/ORSet.json create mode 100644 packages/spec/json-schema/system/ORSetElement.json create mode 100644 packages/spec/json-schema/system/OTComponent.json create mode 100644 packages/spec/json-schema/system/OTOperation.json create mode 100644 packages/spec/json-schema/system/OTOperationType.json create mode 100644 packages/spec/json-schema/system/OTTransformResult.json create mode 100644 packages/spec/json-schema/system/PNCounter.json create mode 100644 packages/spec/json-schema/system/TextCRDTOperation.json create mode 100644 packages/spec/json-schema/system/TextCRDTState.json create mode 100644 packages/spec/json-schema/system/UserActivityStatus.json create mode 100644 packages/spec/json-schema/system/VectorClock.json create mode 100644 packages/spec/src/api/websocket.test.ts create mode 100644 packages/spec/src/api/websocket.zod.ts create mode 100644 packages/spec/src/system/collaboration.test.ts create mode 100644 packages/spec/src/system/collaboration.zod.ts diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index 980afbd4b..782c27f56 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -15,5 +15,6 @@ This section contains all protocol schemas for the api layer of ObjectStack. + diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index 7b29372f8..b72377cec 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -7,6 +7,7 @@ "graphql", "odata", "realtime", - "router" + "router", + "websocket" ] } \ No newline at end of file diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx new file mode 100644 index 000000000..539c1ae54 --- /dev/null +++ b/content/docs/references/api/websocket.mdx @@ -0,0 +1,374 @@ +--- +title: Websocket +description: Websocket protocol schemas +--- + +# Websocket + + +**Source:** `packages/spec/src/api/websocket.zod.ts` + + +## TypeScript Usage + +```typescript +import { AckMessageSchema, CursorMessageSchema, CursorPositionSchema, DocumentStateSchema, EditMessageSchema, EditOperationSchema, EditOperationTypeSchema, ErrorMessageSchema, EventFilterSchema, EventFilterConditionSchema, EventMessageSchema, EventPatternSchema, EventSubscriptionSchema, FilterOperatorSchema, PingMessageSchema, PongMessageSchema, PresenceMessageSchema, PresenceStateSchema, PresenceUpdateSchema, SubscribeMessageSchema, UnsubscribeMessageSchema, UnsubscribeRequestSchema, WebSocketConfigSchema, WebSocketMessageSchema, WebSocketMessageTypeSchema, WebSocketPresenceStatusSchema } from '@objectstack/spec/api'; +import type { AckMessage, CursorMessage, CursorPosition, DocumentState, EditMessage, EditOperation, EditOperationType, ErrorMessage, EventFilter, EventFilterCondition, EventMessage, EventPattern, EventSubscription, FilterOperator, PingMessage, PongMessage, PresenceMessage, PresenceState, PresenceUpdate, SubscribeMessage, UnsubscribeMessage, UnsubscribeRequest, WebSocketConfig, WebSocketMessage, WebSocketMessageType, WebSocketPresenceStatus } from '@objectstack/spec/api'; + +// Validate data +const result = AckMessageSchema.parse(data); +``` + +--- + +## AckMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **ackMessageId** | `string` | ✅ | ID of the message being acknowledged | +| **success** | `boolean` | ✅ | Whether the operation was successful | +| **error** | `string` | optional | Error message if operation failed | + +--- + +## CursorMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **cursor** | `object` | ✅ | Cursor position | + +--- + +## CursorPosition + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **userId** | `string` | ✅ | User identifier | +| **sessionId** | `string` | ✅ | Session identifier | +| **documentId** | `string` | ✅ | Document identifier being edited | +| **position** | `object` | optional | Cursor position in document | +| **selection** | `object` | optional | Selection range (if text is selected) | +| **color** | `string` | optional | Cursor color for visual representation | +| **userName** | `string` | optional | Display name of user | +| **lastUpdate** | `string` | ✅ | ISO 8601 datetime of last cursor update | + +--- + +## DocumentState + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **documentId** | `string` | ✅ | Document identifier | +| **version** | `integer` | ✅ | Current document version | +| **content** | `string` | ✅ | Current document content | +| **lastModified** | `string` | ✅ | ISO 8601 datetime of last modification | +| **activeSessions** | `string[]` | ✅ | Active editing session IDs | +| **checksum** | `string` | optional | Content checksum for integrity verification | + +--- + +## EditMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **operation** | `object` | ✅ | Edit operation | + +--- + +## EditOperation + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **operationId** | `string` | ✅ | Unique operation identifier | +| **documentId** | `string` | ✅ | Document identifier | +| **userId** | `string` | ✅ | User who performed the edit | +| **sessionId** | `string` | ✅ | Session identifier | +| **type** | `Enum<'insert' \| 'delete' \| 'replace'>` | ✅ | Type of edit operation | +| **position** | `object` | ✅ | Starting position of the operation | +| **endPosition** | `object` | optional | Ending position (for delete/replace operations) | +| **content** | `string` | optional | Content to insert/replace | +| **version** | `integer` | ✅ | Document version before this operation | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when operation was created | +| **baseOperationId** | `string` | optional | Previous operation ID this builds upon (for OT) | + +--- + +## EditOperationType + +### Allowed Values + +* `insert` +* `delete` +* `replace` + +--- + +## ErrorMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **code** | `string` | ✅ | Error code | +| **message** | `string` | ✅ | Error message | +| **details** | `any` | optional | Additional error details | + +--- + +## EventFilter + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **conditions** | `object[]` | optional | Array of filter conditions | +| **and** | `any[]` | optional | AND logical combination of filters | +| **or** | `any[]` | optional | OR logical combination of filters | +| **not** | `any` | optional | NOT logical negation of filter | + +--- + +## EventFilterCondition + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field path to filter on (supports dot notation, e.g., "user.email") | +| **operator** | `Enum<'eq' \| 'ne' \| 'gt' \| 'gte' \| 'lt' \| 'lte' \| 'in' \| 'nin' \| 'contains' \| 'startsWith' \| 'endsWith' \| 'exists' \| 'regex'>` | ✅ | Comparison operator | +| **value** | `any` | optional | Value to compare against (not needed for "exists" operator) | + +--- + +## EventMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **subscriptionId** | `string` | ✅ | Subscription ID this event belongs to | +| **eventName** | `string` | ✅ | Event name | +| **object** | `string` | optional | Object name the event relates to | +| **payload** | `any` | optional | Event payload data | +| **userId** | `string` | optional | User who triggered the event | + +--- + +## EventPattern + +Event pattern (supports wildcards like "record.*" or "*.created") + +--- + +## EventSubscription + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **subscriptionId** | `string` | ✅ | Unique subscription identifier | +| **events** | `string[]` | ✅ | Event patterns to subscribe to (supports wildcards, e.g., "record.*", "user.created") | +| **objects** | `string[]` | optional | Object names to filter events by (e.g., ["account", "contact"]) | +| **filters** | `object` | optional | Advanced filter conditions for event payloads | +| **channels** | `string[]` | optional | Channel names for scoped subscriptions | + +--- + +## FilterOperator + +### Allowed Values + +* `eq` +* `ne` +* `gt` +* `gte` +* `lt` +* `lte` +* `in` +* `nin` +* `contains` +* `startsWith` +* `endsWith` +* `exists` +* `regex` + +--- + +## PingMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | + +--- + +## PongMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **pingMessageId** | `string` | optional | ID of ping message being responded to | + +--- + +## PresenceMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **presence** | `object` | ✅ | Presence state | + +--- + +## PresenceState + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **userId** | `string` | ✅ | User identifier | +| **sessionId** | `string` | ✅ | Unique session identifier | +| **status** | `Enum<'online' \| 'away' \| 'busy' \| 'offline'>` | ✅ | Current presence status | +| **lastSeen** | `string` | ✅ | ISO 8601 datetime of last activity | +| **currentLocation** | `string` | optional | Current page/route user is viewing | +| **device** | `Enum<'desktop' \| 'mobile' \| 'tablet' \| 'other'>` | optional | Device type | +| **customStatus** | `string` | optional | Custom user status message | +| **metadata** | `Record` | optional | Additional custom presence data | + +--- + +## PresenceUpdate + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **status** | `Enum<'online' \| 'away' \| 'busy' \| 'offline'>` | optional | Updated presence status | +| **currentLocation** | `string` | optional | Updated current location | +| **customStatus** | `string` | optional | Updated custom status message | +| **metadata** | `Record` | optional | Updated metadata | + +--- + +## SubscribeMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **subscription** | `object` | ✅ | Subscription configuration | + +--- + +## UnsubscribeMessage + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messageId** | `string` | ✅ | Unique message identifier | +| **type** | `string` | ✅ | | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | +| **request** | `object` | ✅ | Unsubscribe request | + +--- + +## UnsubscribeRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **subscriptionId** | `string` | ✅ | Subscription ID to unsubscribe from | + +--- + +## WebSocketConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | ✅ | WebSocket server URL | +| **protocols** | `string[]` | optional | WebSocket sub-protocols | +| **reconnect** | `boolean` | optional | Enable automatic reconnection | +| **reconnectInterval** | `integer` | optional | Reconnection interval in milliseconds | +| **maxReconnectAttempts** | `integer` | optional | Maximum reconnection attempts | +| **pingInterval** | `integer` | optional | Ping interval in milliseconds | +| **timeout** | `integer` | optional | Message timeout in milliseconds | +| **headers** | `Record` | optional | Custom headers for WebSocket handshake | + +--- + +## WebSocketMessage + +--- + +## WebSocketMessageType + +### Allowed Values + +* `subscribe` +* `unsubscribe` +* `event` +* `ping` +* `pong` +* `ack` +* `error` +* `presence` +* `cursor` +* `edit` + +--- + +## WebSocketPresenceStatus + +### Allowed Values + +* `online` +* `away` +* `busy` +* `offline` + diff --git a/content/docs/references/system/collaboration.mdx b/content/docs/references/system/collaboration.mdx new file mode 100644 index 000000000..44fa75150 --- /dev/null +++ b/content/docs/references/system/collaboration.mdx @@ -0,0 +1,407 @@ +--- +title: Collaboration +description: Collaboration protocol schemas +--- + +# Collaboration + + +**Source:** `packages/spec/src/system/collaboration.zod.ts` + + +## TypeScript Usage + +```typescript +import { AwarenessEventSchema, AwarenessSessionSchema, AwarenessUpdateSchema, AwarenessUserStateSchema, CRDTMergeResultSchema, CRDTStateSchema, CRDTTypeSchema, CollaborationModeSchema, CollaborationSessionSchema, CollaborationSessionConfigSchema, CollaborativeCursorSchema, CounterOperationSchema, CursorColorPresetSchema, CursorSelectionSchema, CursorStyleSchema, CursorUpdateSchema, GCounterSchema, LWWRegisterSchema, ORSetSchema, ORSetElementSchema, OTComponentSchema, OTOperationSchema, OTOperationTypeSchema, OTTransformResultSchema, PNCounterSchema, TextCRDTOperationSchema, TextCRDTStateSchema, UserActivityStatusSchema, VectorClockSchema } from '@objectstack/spec/system'; +import type { AwarenessEvent, AwarenessSession, AwarenessUpdate, AwarenessUserState, CRDTMergeResult, CRDTState, CRDTType, CollaborationMode, CollaborationSession, CollaborationSessionConfig, CollaborativeCursor, CounterOperation, CursorColorPreset, CursorSelection, CursorStyle, CursorUpdate, GCounter, LWWRegister, ORSet, ORSetElement, OTComponent, OTOperation, OTOperationType, OTTransformResult, PNCounter, TextCRDTOperation, TextCRDTState, UserActivityStatus, VectorClock } from '@objectstack/spec/system'; + +// Validate data +const result = AwarenessEventSchema.parse(data); +``` + +--- + +## AwarenessEvent + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **eventId** | `string` | ✅ | Event identifier | +| **sessionId** | `string` | ✅ | Session identifier | +| **eventType** | `Enum<'user.joined' \| 'user.left' \| 'user.updated' \| 'session.created' \| 'session.ended'>` | ✅ | Type of awareness event | +| **userId** | `string` | optional | User involved in event | +| **timestamp** | `string` | ✅ | ISO 8601 datetime of event | +| **payload** | `any` | optional | Event payload | + +--- + +## AwarenessSession + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **sessionId** | `string` | ✅ | Session identifier | +| **documentId** | `string` | optional | Document ID this session is for | +| **users** | `object[]` | ✅ | Active users in session | +| **startedAt** | `string` | ✅ | ISO 8601 datetime when session started | +| **lastUpdate** | `string` | ✅ | ISO 8601 datetime of last update | +| **metadata** | `Record` | optional | Session metadata | + +--- + +## AwarenessUpdate + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **status** | `Enum<'active' \| 'idle' \| 'viewing' \| 'disconnected'>` | optional | Updated status | +| **currentDocument** | `string` | optional | Updated current document | +| **currentView** | `string` | optional | Updated current view | +| **metadata** | `Record` | optional | Updated metadata | + +--- + +## AwarenessUserState + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **userId** | `string` | ✅ | User identifier | +| **sessionId** | `string` | ✅ | Session identifier | +| **userName** | `string` | ✅ | Display name | +| **userAvatar** | `string` | optional | User avatar URL | +| **status** | `Enum<'active' \| 'idle' \| 'viewing' \| 'disconnected'>` | ✅ | Current activity status | +| **currentDocument** | `string` | optional | Document ID user is currently editing | +| **currentView** | `string` | optional | Current view/page user is on | +| **lastActivity** | `string` | ✅ | ISO 8601 datetime of last activity | +| **joinedAt** | `string` | ✅ | ISO 8601 datetime when user joined session | +| **permissions** | `string[]` | optional | User permissions in this session | +| **metadata** | `Record` | optional | Additional user state metadata | + +--- + +## CRDTMergeResult + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `object \| object \| object \| object \| object` | ✅ | Merged CRDT state | +| **conflicts** | `object[]` | optional | Conflicts encountered during merge | + +--- + +## CRDTState + +--- + +## CRDTType + +### Allowed Values + +* `lww-register` +* `g-counter` +* `pn-counter` +* `g-set` +* `or-set` +* `lww-map` +* `text` +* `tree` +* `json` + +--- + +## CollaborationMode + +### Allowed Values + +* `ot` +* `crdt` +* `lock` +* `hybrid` + +--- + +## CollaborationSession + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **sessionId** | `string` | ✅ | Session identifier | +| **documentId** | `string` | ✅ | Document identifier | +| **config** | `object` | ✅ | Session configuration | +| **users** | `object[]` | ✅ | Active users | +| **cursors** | `object[]` | ✅ | Active cursors | +| **version** | `integer` | ✅ | Current document version | +| **operations** | `object \| object[]` | optional | Recent operations | +| **createdAt** | `string` | ✅ | ISO 8601 datetime when session was created | +| **lastActivity** | `string` | ✅ | ISO 8601 datetime of last activity | +| **status** | `Enum<'active' \| 'idle' \| 'ended'>` | ✅ | Session status | + +--- + +## CollaborationSessionConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **mode** | `Enum<'ot' \| 'crdt' \| 'lock' \| 'hybrid'>` | ✅ | Collaboration mode to use | +| **enableCursorSharing** | `boolean` | optional | Enable cursor sharing | +| **enablePresence** | `boolean` | optional | Enable presence tracking | +| **enableAwareness** | `boolean` | optional | Enable awareness state | +| **maxUsers** | `integer` | optional | Maximum concurrent users | +| **idleTimeout** | `integer` | optional | Idle timeout in milliseconds | +| **conflictResolution** | `Enum<'ot' \| 'crdt' \| 'manual'>` | optional | Conflict resolution strategy | +| **persistence** | `boolean` | optional | Enable operation persistence | +| **snapshot** | `object` | optional | Snapshot configuration | + +--- + +## CollaborativeCursor + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **userId** | `string` | ✅ | User identifier | +| **sessionId** | `string` | ✅ | Session identifier | +| **documentId** | `string` | ✅ | Document identifier | +| **userName** | `string` | ✅ | Display name of user | +| **position** | `object` | ✅ | Current cursor position | +| **selection** | `object` | optional | Current text selection | +| **style** | `object` | ✅ | Visual style for this cursor | +| **isTyping** | `boolean` | optional | Whether user is currently typing | +| **lastUpdate** | `string` | ✅ | ISO 8601 datetime of last cursor update | +| **metadata** | `Record` | optional | Additional cursor metadata | + +--- + +## CounterOperation + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **replicaId** | `string` | ✅ | Replica identifier | +| **delta** | `integer` | ✅ | Change amount (positive for increment, negative for decrement) | +| **timestamp** | `string` | ✅ | ISO 8601 datetime of operation | + +--- + +## CursorColorPreset + +### Allowed Values + +* `blue` +* `green` +* `red` +* `yellow` +* `purple` +* `orange` +* `pink` +* `teal` +* `indigo` +* `cyan` + +--- + +## CursorSelection + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **anchor** | `object` | ✅ | Selection anchor (start point) | +| **focus** | `object` | ✅ | Selection focus (end point) | +| **direction** | `Enum<'forward' \| 'backward'>` | optional | Selection direction | + +--- + +## CursorStyle + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **color** | `Enum<'blue' \| 'green' \| 'red' \| 'yellow' \| 'purple' \| 'orange' \| 'pink' \| 'teal' \| 'indigo' \| 'cyan'> \| string` | ✅ | Cursor color (preset or custom hex) | +| **opacity** | `number` | optional | Cursor opacity (0-1) | +| **label** | `string` | optional | Label to display with cursor (usually username) | +| **showLabel** | `boolean` | optional | Whether to show label | +| **pulseOnUpdate** | `boolean` | optional | Whether to pulse when cursor moves | + +--- + +## CursorUpdate + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **position** | `object` | optional | Updated cursor position | +| **selection** | `object` | optional | Updated selection | +| **isTyping** | `boolean` | optional | Updated typing state | +| **metadata** | `Record` | optional | Updated metadata | + +--- + +## GCounter + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **counts** | `Record` | ✅ | Map of replica ID to count | + +--- + +## LWWRegister + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **value** | `any` | optional | Current register value | +| **timestamp** | `string` | ✅ | ISO 8601 datetime of last write | +| **replicaId** | `string` | ✅ | ID of replica that performed last write | +| **vectorClock** | `object` | optional | Optional vector clock for causality tracking | + +--- + +## ORSet + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **elements** | `object[]` | ✅ | Set elements with metadata | + +--- + +## ORSetElement + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **value** | `any` | optional | Element value | +| **timestamp** | `string` | ✅ | Addition timestamp | +| **replicaId** | `string` | ✅ | Replica that added the element | +| **uid** | `string` | ✅ | Unique identifier for this addition | +| **removed** | `boolean` | optional | Whether element has been removed | + +--- + +## OTComponent + +--- + +## OTOperation + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **operationId** | `string` | ✅ | Unique operation identifier | +| **documentId** | `string` | ✅ | Document identifier | +| **userId** | `string` | ✅ | User who created the operation | +| **sessionId** | `string` | ✅ | Session identifier | +| **components** | `object \| object \| object[]` | ✅ | Operation components | +| **baseVersion** | `integer` | ✅ | Document version this operation is based on | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when operation was created | +| **metadata** | `Record` | optional | Additional operation metadata | + +--- + +## OTOperationType + +### Allowed Values + +* `insert` +* `delete` +* `retain` + +--- + +## OTTransformResult + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **operation** | `object` | ✅ | Transformed operation | +| **transformed** | `boolean` | ✅ | Whether transformation was applied | +| **conflicts** | `string[]` | optional | Conflict descriptions if any | + +--- + +## PNCounter + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **positive** | `Record` | ✅ | Positive increments per replica | +| **negative** | `Record` | ✅ | Negative increments per replica | + +--- + +## TextCRDTOperation + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **operationId** | `string` | ✅ | Unique operation identifier | +| **replicaId** | `string` | ✅ | Replica identifier | +| **position** | `integer` | ✅ | Position in document | +| **insert** | `string` | optional | Text to insert | +| **delete** | `integer` | optional | Number of characters to delete | +| **timestamp** | `string` | ✅ | ISO 8601 datetime of operation | +| **lamportTimestamp** | `integer` | ✅ | Lamport timestamp for ordering | + +--- + +## TextCRDTState + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | | +| **documentId** | `string` | ✅ | Document identifier | +| **content** | `string` | ✅ | Current text content | +| **operations** | `object[]` | ✅ | History of operations | +| **lamportClock** | `integer` | ✅ | Current Lamport clock value | +| **vectorClock** | `object` | ✅ | Vector clock for causality | + +--- + +## UserActivityStatus + +### Allowed Values + +* `active` +* `idle` +* `viewing` +* `disconnected` + +--- + +## VectorClock + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **clock** | `Record` | ✅ | Map of replica ID to logical timestamp | + diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx index 4883942a9..9fcdb2f8f 100644 --- a/content/docs/references/system/index.mdx +++ b/content/docs/references/system/index.mdx @@ -9,6 +9,7 @@ This section contains all protocol schemas for the system layer of ObjectStack. + diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 491685715..d4a87c4ba 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -2,6 +2,7 @@ "title": "System Protocol", "pages": [ "audit", + "collaboration", "context", "data-engine", "datasource", diff --git a/packages/spec/json-schema/api/AckMessage.json b/packages/spec/json-schema/api/AckMessage.json new file mode 100644 index 000000000..35e184a08 --- /dev/null +++ b/packages/spec/json-schema/api/AckMessage.json @@ -0,0 +1,46 @@ +{ + "$ref": "#/definitions/AckMessage", + "definitions": { + "AckMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "ack" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "ackMessageId": { + "type": "string", + "format": "uuid", + "description": "ID of the message being acknowledged" + }, + "success": { + "type": "boolean", + "description": "Whether the operation was successful" + }, + "error": { + "type": "string", + "description": "Error message if operation failed" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "ackMessageId", + "success" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/CursorMessage.json b/packages/spec/json-schema/api/CursorMessage.json new file mode 100644 index 000000000..41e76989f --- /dev/null +++ b/packages/spec/json-schema/api/CursorMessage.json @@ -0,0 +1,139 @@ +{ + "$ref": "#/definitions/CursorMessage", + "definitions": { + "CursorMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "cursor" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "cursor": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier being edited" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Cursor position in document" + }, + "selection": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "description": "Selection range (if text is selected)" + }, + "color": { + "type": "string", + "description": "Cursor color for visual representation" + }, + "userName": { + "type": "string", + "description": "Display name of user" + }, + "lastUpdate": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last cursor update" + } + }, + "required": [ + "userId", + "sessionId", + "documentId", + "lastUpdate" + ], + "additionalProperties": false, + "description": "Cursor position" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "cursor" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/CursorPosition.json b/packages/spec/json-schema/api/CursorPosition.json new file mode 100644 index 000000000..e53de95f5 --- /dev/null +++ b/packages/spec/json-schema/api/CursorPosition.json @@ -0,0 +1,112 @@ +{ + "$ref": "#/definitions/CursorPosition", + "definitions": { + "CursorPosition": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier being edited" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Cursor position in document" + }, + "selection": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "description": "Selection range (if text is selected)" + }, + "color": { + "type": "string", + "description": "Cursor color for visual representation" + }, + "userName": { + "type": "string", + "description": "Display name of user" + }, + "lastUpdate": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last cursor update" + } + }, + "required": [ + "userId", + "sessionId", + "documentId", + "lastUpdate" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/DocumentState.json b/packages/spec/json-schema/api/DocumentState.json new file mode 100644 index 000000000..72991707e --- /dev/null +++ b/packages/spec/json-schema/api/DocumentState.json @@ -0,0 +1,49 @@ +{ + "$ref": "#/definitions/DocumentState", + "definitions": { + "DocumentState": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Current document version" + }, + "content": { + "type": "string", + "description": "Current document content" + }, + "lastModified": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last modification" + }, + "activeSessions": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Active editing session IDs" + }, + "checksum": { + "type": "string", + "description": "Content checksum for integrity verification" + } + }, + "required": [ + "documentId", + "version", + "content", + "lastModified", + "activeSessions" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EditMessage.json b/packages/spec/json-schema/api/EditMessage.json new file mode 100644 index 000000000..473001658 --- /dev/null +++ b/packages/spec/json-schema/api/EditMessage.json @@ -0,0 +1,135 @@ +{ + "$ref": "#/definitions/EditMessage", + "definitions": { + "EditMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "edit" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "operation": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userId": { + "type": "string", + "description": "User who performed the edit" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "type": { + "type": "string", + "enum": [ + "insert", + "delete", + "replace" + ], + "description": "Type of edit operation" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Starting position of the operation" + }, + "endPosition": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Ending position (for delete/replace operations)" + }, + "content": { + "type": "string", + "description": "Content to insert/replace" + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Document version before this operation" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when operation was created" + }, + "baseOperationId": { + "type": "string", + "format": "uuid", + "description": "Previous operation ID this builds upon (for OT)" + } + }, + "required": [ + "operationId", + "documentId", + "userId", + "sessionId", + "type", + "position", + "version", + "timestamp" + ], + "additionalProperties": false, + "description": "Edit operation" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "operation" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EditOperation.json b/packages/spec/json-schema/api/EditOperation.json new file mode 100644 index 000000000..d3856d757 --- /dev/null +++ b/packages/spec/json-schema/api/EditOperation.json @@ -0,0 +1,108 @@ +{ + "$ref": "#/definitions/EditOperation", + "definitions": { + "EditOperation": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userId": { + "type": "string", + "description": "User who performed the edit" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "type": { + "type": "string", + "enum": [ + "insert", + "delete", + "replace" + ], + "description": "Type of edit operation" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Starting position of the operation" + }, + "endPosition": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Ending position (for delete/replace operations)" + }, + "content": { + "type": "string", + "description": "Content to insert/replace" + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Document version before this operation" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when operation was created" + }, + "baseOperationId": { + "type": "string", + "format": "uuid", + "description": "Previous operation ID this builds upon (for OT)" + } + }, + "required": [ + "operationId", + "documentId", + "userId", + "sessionId", + "type", + "position", + "version", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EditOperationType.json b/packages/spec/json-schema/api/EditOperationType.json new file mode 100644 index 000000000..2019f6972 --- /dev/null +++ b/packages/spec/json-schema/api/EditOperationType.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/EditOperationType", + "definitions": { + "EditOperationType": { + "type": "string", + "enum": [ + "insert", + "delete", + "replace" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/ErrorMessage.json b/packages/spec/json-schema/api/ErrorMessage.json new file mode 100644 index 000000000..f91ac4703 --- /dev/null +++ b/packages/spec/json-schema/api/ErrorMessage.json @@ -0,0 +1,44 @@ +{ + "$ref": "#/definitions/ErrorMessage", + "definitions": { + "ErrorMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "error" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "code": { + "type": "string", + "description": "Error code" + }, + "message": { + "type": "string", + "description": "Error message" + }, + "details": { + "description": "Additional error details" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "code", + "message" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EventFilter.json b/packages/spec/json-schema/api/EventFilter.json new file mode 100644 index 000000000..ca8e2b8d7 --- /dev/null +++ b/packages/spec/json-schema/api/EventFilter.json @@ -0,0 +1,65 @@ +{ + "$ref": "#/definitions/EventFilter", + "definitions": { + "EventFilter": { + "type": "object", + "properties": { + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field path to filter on (supports dot notation, e.g., \"user.email\")" + }, + "operator": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "startsWith", + "endsWith", + "exists", + "regex" + ], + "description": "Comparison operator" + }, + "value": { + "description": "Value to compare against (not needed for \"exists\" operator)" + } + }, + "required": [ + "field", + "operator" + ], + "additionalProperties": false + }, + "description": "Array of filter conditions" + }, + "and": { + "type": "array", + "items": {}, + "description": "AND logical combination of filters" + }, + "or": { + "type": "array", + "items": {}, + "description": "OR logical combination of filters" + }, + "not": { + "description": "NOT logical negation of filter" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EventFilterCondition.json b/packages/spec/json-schema/api/EventFilterCondition.json new file mode 100644 index 000000000..636c512dd --- /dev/null +++ b/packages/spec/json-schema/api/EventFilterCondition.json @@ -0,0 +1,42 @@ +{ + "$ref": "#/definitions/EventFilterCondition", + "definitions": { + "EventFilterCondition": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field path to filter on (supports dot notation, e.g., \"user.email\")" + }, + "operator": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "startsWith", + "endsWith", + "exists", + "regex" + ], + "description": "Comparison operator" + }, + "value": { + "description": "Value to compare against (not needed for \"exists\" operator)" + } + }, + "required": [ + "field", + "operator" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EventMessage.json b/packages/spec/json-schema/api/EventMessage.json new file mode 100644 index 000000000..4fcb8700d --- /dev/null +++ b/packages/spec/json-schema/api/EventMessage.json @@ -0,0 +1,55 @@ +{ + "$ref": "#/definitions/EventMessage", + "definitions": { + "EventMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "event" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Subscription ID this event belongs to" + }, + "eventName": { + "type": "string", + "minLength": 3, + "pattern": "^[a-z][a-z0-9_.]*$", + "description": "Event name" + }, + "object": { + "type": "string", + "description": "Object name the event relates to" + }, + "payload": { + "description": "Event payload data" + }, + "userId": { + "type": "string", + "description": "User who triggered the event" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "subscriptionId", + "eventName" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EventPattern.json b/packages/spec/json-schema/api/EventPattern.json new file mode 100644 index 000000000..bfb7b203a --- /dev/null +++ b/packages/spec/json-schema/api/EventPattern.json @@ -0,0 +1,12 @@ +{ + "$ref": "#/definitions/EventPattern", + "definitions": { + "EventPattern": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z*][a-z0-9_.*]*$", + "description": "Event pattern (supports wildcards like \"record.*\" or \"*.created\")" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/EventSubscription.json b/packages/spec/json-schema/api/EventSubscription.json new file mode 100644 index 000000000..1b4336fb3 --- /dev/null +++ b/packages/spec/json-schema/api/EventSubscription.json @@ -0,0 +1,105 @@ +{ + "$ref": "#/definitions/EventSubscription", + "definitions": { + "EventSubscription": { + "type": "object", + "properties": { + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Unique subscription identifier" + }, + "events": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z*][a-z0-9_.*]*$", + "description": "Event pattern (supports wildcards like \"record.*\" or \"*.created\")" + }, + "description": "Event patterns to subscribe to (supports wildcards, e.g., \"record.*\", \"user.created\")" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Object names to filter events by (e.g., [\"account\", \"contact\"])" + }, + "filters": { + "type": "object", + "properties": { + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field path to filter on (supports dot notation, e.g., \"user.email\")" + }, + "operator": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "startsWith", + "endsWith", + "exists", + "regex" + ], + "description": "Comparison operator" + }, + "value": { + "description": "Value to compare against (not needed for \"exists\" operator)" + } + }, + "required": [ + "field", + "operator" + ], + "additionalProperties": false + }, + "description": "Array of filter conditions" + }, + "and": { + "type": "array", + "items": {}, + "description": "AND logical combination of filters" + }, + "or": { + "type": "array", + "items": {}, + "description": "OR logical combination of filters" + }, + "not": { + "description": "NOT logical negation of filter" + } + }, + "additionalProperties": false, + "description": "Advanced filter conditions for event payloads" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Channel names for scoped subscriptions" + } + }, + "required": [ + "subscriptionId", + "events" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/FilterOperator.json b/packages/spec/json-schema/api/FilterOperator.json new file mode 100644 index 000000000..a9c7a7d7b --- /dev/null +++ b/packages/spec/json-schema/api/FilterOperator.json @@ -0,0 +1,24 @@ +{ + "$ref": "#/definitions/FilterOperator", + "definitions": { + "FilterOperator": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "startsWith", + "endsWith", + "exists", + "regex" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/PingMessage.json b/packages/spec/json-schema/api/PingMessage.json new file mode 100644 index 000000000..b6173c4a3 --- /dev/null +++ b/packages/spec/json-schema/api/PingMessage.json @@ -0,0 +1,31 @@ +{ + "$ref": "#/definitions/PingMessage", + "definitions": { + "PingMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "ping" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + } + }, + "required": [ + "messageId", + "type", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/PongMessage.json b/packages/spec/json-schema/api/PongMessage.json new file mode 100644 index 000000000..572edd427 --- /dev/null +++ b/packages/spec/json-schema/api/PongMessage.json @@ -0,0 +1,36 @@ +{ + "$ref": "#/definitions/PongMessage", + "definitions": { + "PongMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "pong" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "pingMessageId": { + "type": "string", + "format": "uuid", + "description": "ID of ping message being responded to" + } + }, + "required": [ + "messageId", + "type", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/PresenceMessage.json b/packages/spec/json-schema/api/PresenceMessage.json new file mode 100644 index 000000000..5dd125edb --- /dev/null +++ b/packages/spec/json-schema/api/PresenceMessage.json @@ -0,0 +1,92 @@ +{ + "$ref": "#/definitions/PresenceMessage", + "definitions": { + "PresenceMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "presence" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "presence": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Unique session identifier" + }, + "status": { + "type": "string", + "enum": [ + "online", + "away", + "busy", + "offline" + ], + "description": "Current presence status" + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "currentLocation": { + "type": "string", + "description": "Current page/route user is viewing" + }, + "device": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet", + "other" + ], + "description": "Device type" + }, + "customStatus": { + "type": "string", + "description": "Custom user status message" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional custom presence data" + } + }, + "required": [ + "userId", + "sessionId", + "status", + "lastSeen" + ], + "additionalProperties": false, + "description": "Presence state" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "presence" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/PresenceState.json b/packages/spec/json-schema/api/PresenceState.json new file mode 100644 index 000000000..e3144d289 --- /dev/null +++ b/packages/spec/json-schema/api/PresenceState.json @@ -0,0 +1,65 @@ +{ + "$ref": "#/definitions/PresenceState", + "definitions": { + "PresenceState": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Unique session identifier" + }, + "status": { + "type": "string", + "enum": [ + "online", + "away", + "busy", + "offline" + ], + "description": "Current presence status" + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "currentLocation": { + "type": "string", + "description": "Current page/route user is viewing" + }, + "device": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet", + "other" + ], + "description": "Device type" + }, + "customStatus": { + "type": "string", + "description": "Custom user status message" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional custom presence data" + } + }, + "required": [ + "userId", + "sessionId", + "status", + "lastSeen" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/PresenceUpdate.json b/packages/spec/json-schema/api/PresenceUpdate.json new file mode 100644 index 000000000..123b6bf71 --- /dev/null +++ b/packages/spec/json-schema/api/PresenceUpdate.json @@ -0,0 +1,35 @@ +{ + "$ref": "#/definitions/PresenceUpdate", + "definitions": { + "PresenceUpdate": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "online", + "away", + "busy", + "offline" + ], + "description": "Updated presence status" + }, + "currentLocation": { + "type": "string", + "description": "Updated current location" + }, + "customStatus": { + "type": "string", + "description": "Updated custom status message" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Updated metadata" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/SubscribeMessage.json b/packages/spec/json-schema/api/SubscribeMessage.json new file mode 100644 index 000000000..6ca6fcded --- /dev/null +++ b/packages/spec/json-schema/api/SubscribeMessage.json @@ -0,0 +1,132 @@ +{ + "$ref": "#/definitions/SubscribeMessage", + "definitions": { + "SubscribeMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "subscribe" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "subscription": { + "type": "object", + "properties": { + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Unique subscription identifier" + }, + "events": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z*][a-z0-9_.*]*$", + "description": "Event pattern (supports wildcards like \"record.*\" or \"*.created\")" + }, + "description": "Event patterns to subscribe to (supports wildcards, e.g., \"record.*\", \"user.created\")" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Object names to filter events by (e.g., [\"account\", \"contact\"])" + }, + "filters": { + "type": "object", + "properties": { + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field path to filter on (supports dot notation, e.g., \"user.email\")" + }, + "operator": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "startsWith", + "endsWith", + "exists", + "regex" + ], + "description": "Comparison operator" + }, + "value": { + "description": "Value to compare against (not needed for \"exists\" operator)" + } + }, + "required": [ + "field", + "operator" + ], + "additionalProperties": false + }, + "description": "Array of filter conditions" + }, + "and": { + "type": "array", + "items": {}, + "description": "AND logical combination of filters" + }, + "or": { + "type": "array", + "items": {}, + "description": "OR logical combination of filters" + }, + "not": { + "description": "NOT logical negation of filter" + } + }, + "additionalProperties": false, + "description": "Advanced filter conditions for event payloads" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Channel names for scoped subscriptions" + } + }, + "required": [ + "subscriptionId", + "events" + ], + "additionalProperties": false, + "description": "Subscription configuration" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "subscription" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/UnsubscribeMessage.json b/packages/spec/json-schema/api/UnsubscribeMessage.json new file mode 100644 index 000000000..87d4d1dea --- /dev/null +++ b/packages/spec/json-schema/api/UnsubscribeMessage.json @@ -0,0 +1,47 @@ +{ + "$ref": "#/definitions/UnsubscribeMessage", + "definitions": { + "UnsubscribeMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "unsubscribe" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "request": { + "type": "object", + "properties": { + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Subscription ID to unsubscribe from" + } + }, + "required": [ + "subscriptionId" + ], + "additionalProperties": false, + "description": "Unsubscribe request" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "request" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/UnsubscribeRequest.json b/packages/spec/json-schema/api/UnsubscribeRequest.json new file mode 100644 index 000000000..8bb1c3493 --- /dev/null +++ b/packages/spec/json-schema/api/UnsubscribeRequest.json @@ -0,0 +1,20 @@ +{ + "$ref": "#/definitions/UnsubscribeRequest", + "definitions": { + "UnsubscribeRequest": { + "type": "object", + "properties": { + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Subscription ID to unsubscribe from" + } + }, + "required": [ + "subscriptionId" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/WebSocketConfig.json b/packages/spec/json-schema/api/WebSocketConfig.json new file mode 100644 index 000000000..cf7150cf6 --- /dev/null +++ b/packages/spec/json-schema/api/WebSocketConfig.json @@ -0,0 +1,63 @@ +{ + "$ref": "#/definitions/WebSocketConfig", + "definitions": { + "WebSocketConfig": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "WebSocket server URL" + }, + "protocols": { + "type": "array", + "items": { + "type": "string" + }, + "description": "WebSocket sub-protocols" + }, + "reconnect": { + "type": "boolean", + "default": true, + "description": "Enable automatic reconnection" + }, + "reconnectInterval": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 1000, + "description": "Reconnection interval in milliseconds" + }, + "maxReconnectAttempts": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 5, + "description": "Maximum reconnection attempts" + }, + "pingInterval": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 30000, + "description": "Ping interval in milliseconds" + }, + "timeout": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 5000, + "description": "Message timeout in milliseconds" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers for WebSocket handshake" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/WebSocketMessage.json b/packages/spec/json-schema/api/WebSocketMessage.json new file mode 100644 index 000000000..0cae16371 --- /dev/null +++ b/packages/spec/json-schema/api/WebSocketMessage.json @@ -0,0 +1,707 @@ +{ + "$ref": "#/definitions/WebSocketMessage", + "definitions": { + "WebSocketMessage": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "subscribe" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "subscription": { + "type": "object", + "properties": { + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Unique subscription identifier" + }, + "events": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z*][a-z0-9_.*]*$", + "description": "Event pattern (supports wildcards like \"record.*\" or \"*.created\")" + }, + "description": "Event patterns to subscribe to (supports wildcards, e.g., \"record.*\", \"user.created\")" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Object names to filter events by (e.g., [\"account\", \"contact\"])" + }, + "filters": { + "type": "object", + "properties": { + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field path to filter on (supports dot notation, e.g., \"user.email\")" + }, + "operator": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "startsWith", + "endsWith", + "exists", + "regex" + ], + "description": "Comparison operator" + }, + "value": { + "description": "Value to compare against (not needed for \"exists\" operator)" + } + }, + "required": [ + "field", + "operator" + ], + "additionalProperties": false + }, + "description": "Array of filter conditions" + }, + "and": { + "type": "array", + "items": {}, + "description": "AND logical combination of filters" + }, + "or": { + "type": "array", + "items": {}, + "description": "OR logical combination of filters" + }, + "not": { + "description": "NOT logical negation of filter" + } + }, + "additionalProperties": false, + "description": "Advanced filter conditions for event payloads" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Channel names for scoped subscriptions" + } + }, + "required": [ + "subscriptionId", + "events" + ], + "additionalProperties": false, + "description": "Subscription configuration" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "subscription" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "unsubscribe" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "request": { + "type": "object", + "properties": { + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Subscription ID to unsubscribe from" + } + }, + "required": [ + "subscriptionId" + ], + "additionalProperties": false, + "description": "Unsubscribe request" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "request" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "event" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "subscriptionId": { + "type": "string", + "format": "uuid", + "description": "Subscription ID this event belongs to" + }, + "eventName": { + "type": "string", + "minLength": 3, + "pattern": "^[a-z][a-z0-9_.]*$", + "description": "Event name" + }, + "object": { + "type": "string", + "description": "Object name the event relates to" + }, + "payload": { + "description": "Event payload data" + }, + "userId": { + "type": "string", + "description": "User who triggered the event" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "subscriptionId", + "eventName" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "presence" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "presence": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Unique session identifier" + }, + "status": { + "type": "string", + "enum": [ + "online", + "away", + "busy", + "offline" + ], + "description": "Current presence status" + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "currentLocation": { + "type": "string", + "description": "Current page/route user is viewing" + }, + "device": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet", + "other" + ], + "description": "Device type" + }, + "customStatus": { + "type": "string", + "description": "Custom user status message" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional custom presence data" + } + }, + "required": [ + "userId", + "sessionId", + "status", + "lastSeen" + ], + "additionalProperties": false, + "description": "Presence state" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "presence" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "cursor" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "cursor": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier being edited" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Cursor position in document" + }, + "selection": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "description": "Selection range (if text is selected)" + }, + "color": { + "type": "string", + "description": "Cursor color for visual representation" + }, + "userName": { + "type": "string", + "description": "Display name of user" + }, + "lastUpdate": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last cursor update" + } + }, + "required": [ + "userId", + "sessionId", + "documentId", + "lastUpdate" + ], + "additionalProperties": false, + "description": "Cursor position" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "cursor" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "edit" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "operation": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userId": { + "type": "string", + "description": "User who performed the edit" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "type": { + "type": "string", + "enum": [ + "insert", + "delete", + "replace" + ], + "description": "Type of edit operation" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Starting position of the operation" + }, + "endPosition": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Ending position (for delete/replace operations)" + }, + "content": { + "type": "string", + "description": "Content to insert/replace" + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Document version before this operation" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when operation was created" + }, + "baseOperationId": { + "type": "string", + "format": "uuid", + "description": "Previous operation ID this builds upon (for OT)" + } + }, + "required": [ + "operationId", + "documentId", + "userId", + "sessionId", + "type", + "position", + "version", + "timestamp" + ], + "additionalProperties": false, + "description": "Edit operation" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "operation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "ack" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "ackMessageId": { + "type": "string", + "format": "uuid", + "description": "ID of the message being acknowledged" + }, + "success": { + "type": "boolean", + "description": "Whether the operation was successful" + }, + "error": { + "type": "string", + "description": "Error message if operation failed" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "ackMessageId", + "success" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "error" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "code": { + "type": "string", + "description": "Error code" + }, + "message": { + "type": "string", + "description": "Error message" + }, + "details": { + "description": "Additional error details" + } + }, + "required": [ + "messageId", + "type", + "timestamp", + "code", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "ping" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + } + }, + "required": [ + "messageId", + "type", + "timestamp" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier" + }, + "type": { + "type": "string", + "const": "pong" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when message was sent" + }, + "pingMessageId": { + "type": "string", + "format": "uuid", + "description": "ID of ping message being responded to" + } + }, + "required": [ + "messageId", + "type", + "timestamp" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/WebSocketMessageType.json b/packages/spec/json-schema/api/WebSocketMessageType.json new file mode 100644 index 000000000..9e0fe0785 --- /dev/null +++ b/packages/spec/json-schema/api/WebSocketMessageType.json @@ -0,0 +1,21 @@ +{ + "$ref": "#/definitions/WebSocketMessageType", + "definitions": { + "WebSocketMessageType": { + "type": "string", + "enum": [ + "subscribe", + "unsubscribe", + "event", + "ping", + "pong", + "ack", + "error", + "presence", + "cursor", + "edit" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/api/WebSocketPresenceStatus.json b/packages/spec/json-schema/api/WebSocketPresenceStatus.json new file mode 100644 index 000000000..b35b753db --- /dev/null +++ b/packages/spec/json-schema/api/WebSocketPresenceStatus.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/WebSocketPresenceStatus", + "definitions": { + "WebSocketPresenceStatus": { + "type": "string", + "enum": [ + "online", + "away", + "busy", + "offline" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/AwarenessEvent.json b/packages/spec/json-schema/system/AwarenessEvent.json new file mode 100644 index 000000000..6de707dbf --- /dev/null +++ b/packages/spec/json-schema/system/AwarenessEvent.json @@ -0,0 +1,51 @@ +{ + "$ref": "#/definitions/AwarenessEvent", + "definitions": { + "AwarenessEvent": { + "type": "object", + "properties": { + "eventId": { + "type": "string", + "format": "uuid", + "description": "Event identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "eventType": { + "type": "string", + "enum": [ + "user.joined", + "user.left", + "user.updated", + "session.created", + "session.ended" + ], + "description": "Type of awareness event" + }, + "userId": { + "type": "string", + "description": "User involved in event" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of event" + }, + "payload": { + "description": "Event payload" + } + }, + "required": [ + "eventId", + "sessionId", + "eventType", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/AwarenessSession.json b/packages/spec/json-schema/system/AwarenessSession.json new file mode 100644 index 000000000..f3a3401a1 --- /dev/null +++ b/packages/spec/json-schema/system/AwarenessSession.json @@ -0,0 +1,117 @@ +{ + "$ref": "#/definitions/AwarenessSession", + "definitions": { + "AwarenessSession": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document ID this session is for" + }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "userName": { + "type": "string", + "description": "Display name" + }, + "userAvatar": { + "type": "string", + "description": "User avatar URL" + }, + "status": { + "type": "string", + "enum": [ + "active", + "idle", + "viewing", + "disconnected" + ], + "description": "Current activity status" + }, + "currentDocument": { + "type": "string", + "description": "Document ID user is currently editing" + }, + "currentView": { + "type": "string", + "description": "Current view/page user is on" + }, + "lastActivity": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when user joined session" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User permissions in this session" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional user state metadata" + } + }, + "required": [ + "userId", + "sessionId", + "userName", + "status", + "lastActivity", + "joinedAt" + ], + "additionalProperties": false + }, + "description": "Active users in session" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when session started" + }, + "lastUpdate": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last update" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Session metadata" + } + }, + "required": [ + "sessionId", + "users", + "startedAt", + "lastUpdate" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/AwarenessUpdate.json b/packages/spec/json-schema/system/AwarenessUpdate.json new file mode 100644 index 000000000..cb06679fc --- /dev/null +++ b/packages/spec/json-schema/system/AwarenessUpdate.json @@ -0,0 +1,35 @@ +{ + "$ref": "#/definitions/AwarenessUpdate", + "definitions": { + "AwarenessUpdate": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "active", + "idle", + "viewing", + "disconnected" + ], + "description": "Updated status" + }, + "currentDocument": { + "type": "string", + "description": "Updated current document" + }, + "currentView": { + "type": "string", + "description": "Updated current view" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Updated metadata" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/AwarenessUserState.json b/packages/spec/json-schema/system/AwarenessUserState.json new file mode 100644 index 000000000..0edcb1090 --- /dev/null +++ b/packages/spec/json-schema/system/AwarenessUserState.json @@ -0,0 +1,77 @@ +{ + "$ref": "#/definitions/AwarenessUserState", + "definitions": { + "AwarenessUserState": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "userName": { + "type": "string", + "description": "Display name" + }, + "userAvatar": { + "type": "string", + "description": "User avatar URL" + }, + "status": { + "type": "string", + "enum": [ + "active", + "idle", + "viewing", + "disconnected" + ], + "description": "Current activity status" + }, + "currentDocument": { + "type": "string", + "description": "Document ID user is currently editing" + }, + "currentView": { + "type": "string", + "description": "Current view/page user is on" + }, + "lastActivity": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when user joined session" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User permissions in this session" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional user state metadata" + } + }, + "required": [ + "userId", + "sessionId", + "userName", + "status", + "lastActivity", + "joinedAt" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CRDTMergeResult.json b/packages/spec/json-schema/system/CRDTMergeResult.json new file mode 100644 index 000000000..84640616d --- /dev/null +++ b/packages/spec/json-schema/system/CRDTMergeResult.json @@ -0,0 +1,295 @@ +{ + "$ref": "#/definitions/CRDTMergeResult", + "definitions": { + "CRDTMergeResult": { + "type": "object", + "properties": { + "state": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lww-register" + }, + "value": { + "description": "Current register value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last write" + }, + "replicaId": { + "type": "string", + "description": "ID of replica that performed last write" + }, + "vectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false, + "description": "Optional vector clock for causality tracking" + } + }, + "required": [ + "type", + "timestamp", + "replicaId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "g-counter" + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to count" + } + }, + "required": [ + "type", + "counts" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pn-counter" + }, + "positive": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Positive increments per replica" + }, + "negative": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Negative increments per replica" + } + }, + "required": [ + "type", + "positive", + "negative" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "or-set" + }, + "elements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "Element value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Addition timestamp" + }, + "replicaId": { + "type": "string", + "description": "Replica that added the element" + }, + "uid": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this addition" + }, + "removed": { + "type": "boolean", + "default": false, + "description": "Whether element has been removed" + } + }, + "required": [ + "timestamp", + "replicaId", + "uid" + ], + "additionalProperties": false + }, + "description": "Set elements with metadata" + } + }, + "required": [ + "type", + "elements" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "content": { + "type": "string", + "description": "Current text content" + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "replicaId": { + "type": "string", + "description": "Replica identifier" + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Position in document" + }, + "insert": { + "type": "string", + "description": "Text to insert" + }, + "delete": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of operation" + }, + "lamportTimestamp": { + "type": "integer", + "minimum": 0, + "description": "Lamport timestamp for ordering" + } + }, + "required": [ + "operationId", + "replicaId", + "position", + "timestamp", + "lamportTimestamp" + ], + "additionalProperties": false + }, + "description": "History of operations" + }, + "lamportClock": { + "type": "integer", + "minimum": 0, + "description": "Current Lamport clock value" + }, + "vectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false, + "description": "Vector clock for causality" + } + }, + "required": [ + "type", + "documentId", + "content", + "operations", + "lamportClock", + "vectorClock" + ], + "additionalProperties": false + } + ], + "description": "Merged CRDT state" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Conflict type" + }, + "description": { + "type": "string", + "description": "Conflict description" + }, + "resolved": { + "type": "boolean", + "description": "Whether conflict was automatically resolved" + } + }, + "required": [ + "type", + "description", + "resolved" + ], + "additionalProperties": false + }, + "description": "Conflicts encountered during merge" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CRDTState.json b/packages/spec/json-schema/system/CRDTState.json new file mode 100644 index 000000000..9354aa577 --- /dev/null +++ b/packages/spec/json-schema/system/CRDTState.json @@ -0,0 +1,258 @@ +{ + "$ref": "#/definitions/CRDTState", + "definitions": { + "CRDTState": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lww-register" + }, + "value": { + "description": "Current register value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last write" + }, + "replicaId": { + "type": "string", + "description": "ID of replica that performed last write" + }, + "vectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false, + "description": "Optional vector clock for causality tracking" + } + }, + "required": [ + "type", + "timestamp", + "replicaId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "g-counter" + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to count" + } + }, + "required": [ + "type", + "counts" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pn-counter" + }, + "positive": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Positive increments per replica" + }, + "negative": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Negative increments per replica" + } + }, + "required": [ + "type", + "positive", + "negative" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "or-set" + }, + "elements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "Element value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Addition timestamp" + }, + "replicaId": { + "type": "string", + "description": "Replica that added the element" + }, + "uid": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this addition" + }, + "removed": { + "type": "boolean", + "default": false, + "description": "Whether element has been removed" + } + }, + "required": [ + "timestamp", + "replicaId", + "uid" + ], + "additionalProperties": false + }, + "description": "Set elements with metadata" + } + }, + "required": [ + "type", + "elements" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "content": { + "type": "string", + "description": "Current text content" + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "replicaId": { + "type": "string", + "description": "Replica identifier" + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Position in document" + }, + "insert": { + "type": "string", + "description": "Text to insert" + }, + "delete": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of operation" + }, + "lamportTimestamp": { + "type": "integer", + "minimum": 0, + "description": "Lamport timestamp for ordering" + } + }, + "required": [ + "operationId", + "replicaId", + "position", + "timestamp", + "lamportTimestamp" + ], + "additionalProperties": false + }, + "description": "History of operations" + }, + "lamportClock": { + "type": "integer", + "minimum": 0, + "description": "Current Lamport clock value" + }, + "vectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false, + "description": "Vector clock for causality" + } + }, + "required": [ + "type", + "documentId", + "content", + "operations", + "lamportClock", + "vectorClock" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CRDTType.json b/packages/spec/json-schema/system/CRDTType.json new file mode 100644 index 000000000..36d88414e --- /dev/null +++ b/packages/spec/json-schema/system/CRDTType.json @@ -0,0 +1,20 @@ +{ + "$ref": "#/definitions/CRDTType", + "definitions": { + "CRDTType": { + "type": "string", + "enum": [ + "lww-register", + "g-counter", + "pn-counter", + "g-set", + "or-set", + "lww-map", + "text", + "tree", + "json" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CollaborationMode.json b/packages/spec/json-schema/system/CollaborationMode.json new file mode 100644 index 000000000..c5a3f3c6c --- /dev/null +++ b/packages/spec/json-schema/system/CollaborationMode.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/CollaborationMode", + "definitions": { + "CollaborationMode": { + "type": "string", + "enum": [ + "ot", + "crdt", + "lock", + "hybrid" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CollaborationSession.json b/packages/spec/json-schema/system/CollaborationSession.json new file mode 100644 index 000000000..5405ce1a4 --- /dev/null +++ b/packages/spec/json-schema/system/CollaborationSession.json @@ -0,0 +1,575 @@ +{ + "$ref": "#/definitions/CollaborationSession", + "definitions": { + "CollaborationSession": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "config": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "ot", + "crdt", + "lock", + "hybrid" + ], + "description": "Collaboration mode to use" + }, + "enableCursorSharing": { + "type": "boolean", + "default": true, + "description": "Enable cursor sharing" + }, + "enablePresence": { + "type": "boolean", + "default": true, + "description": "Enable presence tracking" + }, + "enableAwareness": { + "type": "boolean", + "default": true, + "description": "Enable awareness state" + }, + "maxUsers": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Maximum concurrent users" + }, + "idleTimeout": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 300000, + "description": "Idle timeout in milliseconds" + }, + "conflictResolution": { + "type": "string", + "enum": [ + "ot", + "crdt", + "manual" + ], + "default": "ot", + "description": "Conflict resolution strategy" + }, + "persistence": { + "type": "boolean", + "default": true, + "description": "Enable operation persistence" + }, + "snapshot": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable periodic snapshots" + }, + "interval": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Snapshot interval in milliseconds" + } + }, + "required": [ + "enabled", + "interval" + ], + "additionalProperties": false, + "description": "Snapshot configuration" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false, + "description": "Session configuration" + }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "userName": { + "type": "string", + "description": "Display name" + }, + "userAvatar": { + "type": "string", + "description": "User avatar URL" + }, + "status": { + "type": "string", + "enum": [ + "active", + "idle", + "viewing", + "disconnected" + ], + "description": "Current activity status" + }, + "currentDocument": { + "type": "string", + "description": "Document ID user is currently editing" + }, + "currentView": { + "type": "string", + "description": "Current view/page user is on" + }, + "lastActivity": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when user joined session" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User permissions in this session" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional user state metadata" + } + }, + "required": [ + "userId", + "sessionId", + "userName", + "status", + "lastActivity", + "joinedAt" + ], + "additionalProperties": false + }, + "description": "Active users" + }, + "cursors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userName": { + "type": "string", + "description": "Display name of user" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Cursor line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Cursor column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Current cursor position" + }, + "selection": { + "type": "object", + "properties": { + "anchor": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Anchor line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Anchor column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection anchor (start point)" + }, + "focus": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Focus line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Focus column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection focus (end point)" + }, + "direction": { + "type": "string", + "enum": [ + "forward", + "backward" + ], + "description": "Selection direction" + } + }, + "required": [ + "anchor", + "focus" + ], + "additionalProperties": false, + "description": "Current text selection" + }, + "style": { + "type": "object", + "properties": { + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "blue", + "green", + "red", + "yellow", + "purple", + "orange", + "pink", + "teal", + "indigo", + "cyan" + ] + }, + { + "type": "string" + } + ], + "description": "Cursor color (preset or custom hex)" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 1, + "description": "Cursor opacity (0-1)" + }, + "label": { + "type": "string", + "description": "Label to display with cursor (usually username)" + }, + "showLabel": { + "type": "boolean", + "default": true, + "description": "Whether to show label" + }, + "pulseOnUpdate": { + "type": "boolean", + "default": true, + "description": "Whether to pulse when cursor moves" + } + }, + "required": [ + "color" + ], + "additionalProperties": false, + "description": "Visual style for this cursor" + }, + "isTyping": { + "type": "boolean", + "default": false, + "description": "Whether user is currently typing" + }, + "lastUpdate": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last cursor update" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional cursor metadata" + } + }, + "required": [ + "userId", + "sessionId", + "documentId", + "userName", + "position", + "style", + "lastUpdate" + ], + "additionalProperties": false + }, + "description": "Active cursors" + }, + "version": { + "type": "integer", + "minimum": 0, + "description": "Current document version" + }, + "operations": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userId": { + "type": "string", + "description": "User who created the operation" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "components": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "insert" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Text formatting attributes (e.g., bold, italic)" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delete" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retain" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to retain" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Attribute changes to apply" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + } + ] + }, + "description": "Operation components" + }, + "baseVersion": { + "type": "integer", + "minimum": 0, + "description": "Document version this operation is based on" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when operation was created" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional operation metadata" + } + }, + "required": [ + "operationId", + "documentId", + "userId", + "sessionId", + "components", + "baseVersion", + "timestamp" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "replicaId": { + "type": "string", + "description": "Replica identifier" + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Position in document" + }, + "insert": { + "type": "string", + "description": "Text to insert" + }, + "delete": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of operation" + }, + "lamportTimestamp": { + "type": "integer", + "minimum": 0, + "description": "Lamport timestamp for ordering" + } + }, + "required": [ + "operationId", + "replicaId", + "position", + "timestamp", + "lamportTimestamp" + ], + "additionalProperties": false + } + ] + }, + "description": "Recent operations" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when session was created" + }, + "lastActivity": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last activity" + }, + "status": { + "type": "string", + "enum": [ + "active", + "idle", + "ended" + ], + "description": "Session status" + } + }, + "required": [ + "sessionId", + "documentId", + "config", + "users", + "cursors", + "version", + "createdAt", + "lastActivity", + "status" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CollaborationSessionConfig.json b/packages/spec/json-schema/system/CollaborationSessionConfig.json new file mode 100644 index 000000000..cc7b97fad --- /dev/null +++ b/packages/spec/json-schema/system/CollaborationSessionConfig.json @@ -0,0 +1,86 @@ +{ + "$ref": "#/definitions/CollaborationSessionConfig", + "definitions": { + "CollaborationSessionConfig": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "ot", + "crdt", + "lock", + "hybrid" + ], + "description": "Collaboration mode to use" + }, + "enableCursorSharing": { + "type": "boolean", + "default": true, + "description": "Enable cursor sharing" + }, + "enablePresence": { + "type": "boolean", + "default": true, + "description": "Enable presence tracking" + }, + "enableAwareness": { + "type": "boolean", + "default": true, + "description": "Enable awareness state" + }, + "maxUsers": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Maximum concurrent users" + }, + "idleTimeout": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 300000, + "description": "Idle timeout in milliseconds" + }, + "conflictResolution": { + "type": "string", + "enum": [ + "ot", + "crdt", + "manual" + ], + "default": "ot", + "description": "Conflict resolution strategy" + }, + "persistence": { + "type": "boolean", + "default": true, + "description": "Enable operation persistence" + }, + "snapshot": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable periodic snapshots" + }, + "interval": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Snapshot interval in milliseconds" + } + }, + "required": [ + "enabled", + "interval" + ], + "additionalProperties": false, + "description": "Snapshot configuration" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CollaborativeCursor.json b/packages/spec/json-schema/system/CollaborativeCursor.json new file mode 100644 index 000000000..ccd0f62b4 --- /dev/null +++ b/packages/spec/json-schema/system/CollaborativeCursor.json @@ -0,0 +1,189 @@ +{ + "$ref": "#/definitions/CollaborativeCursor", + "definitions": { + "CollaborativeCursor": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userName": { + "type": "string", + "description": "Display name of user" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Cursor line number (0-indexed)" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Cursor column number (0-indexed)" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Current cursor position" + }, + "selection": { + "type": "object", + "properties": { + "anchor": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Anchor line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Anchor column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection anchor (start point)" + }, + "focus": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Focus line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Focus column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection focus (end point)" + }, + "direction": { + "type": "string", + "enum": [ + "forward", + "backward" + ], + "description": "Selection direction" + } + }, + "required": [ + "anchor", + "focus" + ], + "additionalProperties": false, + "description": "Current text selection" + }, + "style": { + "type": "object", + "properties": { + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "blue", + "green", + "red", + "yellow", + "purple", + "orange", + "pink", + "teal", + "indigo", + "cyan" + ] + }, + { + "type": "string" + } + ], + "description": "Cursor color (preset or custom hex)" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 1, + "description": "Cursor opacity (0-1)" + }, + "label": { + "type": "string", + "description": "Label to display with cursor (usually username)" + }, + "showLabel": { + "type": "boolean", + "default": true, + "description": "Whether to show label" + }, + "pulseOnUpdate": { + "type": "boolean", + "default": true, + "description": "Whether to pulse when cursor moves" + } + }, + "required": [ + "color" + ], + "additionalProperties": false, + "description": "Visual style for this cursor" + }, + "isTyping": { + "type": "boolean", + "default": false, + "description": "Whether user is currently typing" + }, + "lastUpdate": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last cursor update" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional cursor metadata" + } + }, + "required": [ + "userId", + "sessionId", + "documentId", + "userName", + "position", + "style", + "lastUpdate" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CounterOperation.json b/packages/spec/json-schema/system/CounterOperation.json new file mode 100644 index 000000000..6327546e5 --- /dev/null +++ b/packages/spec/json-schema/system/CounterOperation.json @@ -0,0 +1,30 @@ +{ + "$ref": "#/definitions/CounterOperation", + "definitions": { + "CounterOperation": { + "type": "object", + "properties": { + "replicaId": { + "type": "string", + "description": "Replica identifier" + }, + "delta": { + "type": "integer", + "description": "Change amount (positive for increment, negative for decrement)" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of operation" + } + }, + "required": [ + "replicaId", + "delta", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CursorColorPreset.json b/packages/spec/json-schema/system/CursorColorPreset.json new file mode 100644 index 000000000..add047298 --- /dev/null +++ b/packages/spec/json-schema/system/CursorColorPreset.json @@ -0,0 +1,21 @@ +{ + "$ref": "#/definitions/CursorColorPreset", + "definitions": { + "CursorColorPreset": { + "type": "string", + "enum": [ + "blue", + "green", + "red", + "yellow", + "purple", + "orange", + "pink", + "teal", + "indigo", + "cyan" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CursorSelection.json b/packages/spec/json-schema/system/CursorSelection.json new file mode 100644 index 000000000..d000f4cb9 --- /dev/null +++ b/packages/spec/json-schema/system/CursorSelection.json @@ -0,0 +1,66 @@ +{ + "$ref": "#/definitions/CursorSelection", + "definitions": { + "CursorSelection": { + "type": "object", + "properties": { + "anchor": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Anchor line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Anchor column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection anchor (start point)" + }, + "focus": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Focus line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Focus column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection focus (end point)" + }, + "direction": { + "type": "string", + "enum": [ + "forward", + "backward" + ], + "description": "Selection direction" + } + }, + "required": [ + "anchor", + "focus" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CursorStyle.json b/packages/spec/json-schema/system/CursorStyle.json new file mode 100644 index 000000000..660db91e9 --- /dev/null +++ b/packages/spec/json-schema/system/CursorStyle.json @@ -0,0 +1,59 @@ +{ + "$ref": "#/definitions/CursorStyle", + "definitions": { + "CursorStyle": { + "type": "object", + "properties": { + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "blue", + "green", + "red", + "yellow", + "purple", + "orange", + "pink", + "teal", + "indigo", + "cyan" + ] + }, + { + "type": "string" + } + ], + "description": "Cursor color (preset or custom hex)" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 1, + "description": "Cursor opacity (0-1)" + }, + "label": { + "type": "string", + "description": "Label to display with cursor (usually username)" + }, + "showLabel": { + "type": "boolean", + "default": true, + "description": "Whether to show label" + }, + "pulseOnUpdate": { + "type": "boolean", + "default": true, + "description": "Whether to pulse when cursor moves" + } + }, + "required": [ + "color" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CursorUpdate.json b/packages/spec/json-schema/system/CursorUpdate.json new file mode 100644 index 000000000..809273e4a --- /dev/null +++ b/packages/spec/json-schema/system/CursorUpdate.json @@ -0,0 +1,101 @@ +{ + "$ref": "#/definitions/CursorUpdate", + "definitions": { + "CursorUpdate": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "column": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Updated cursor position" + }, + "selection": { + "type": "object", + "properties": { + "anchor": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Anchor line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Anchor column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection anchor (start point)" + }, + "focus": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "description": "Focus line number" + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "Focus column number" + } + }, + "required": [ + "line", + "column" + ], + "additionalProperties": false, + "description": "Selection focus (end point)" + }, + "direction": { + "type": "string", + "enum": [ + "forward", + "backward" + ], + "description": "Selection direction" + } + }, + "required": [ + "anchor", + "focus" + ], + "additionalProperties": false, + "description": "Updated selection" + }, + "isTyping": { + "type": "boolean", + "description": "Updated typing state" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Updated metadata" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/GCounter.json b/packages/spec/json-schema/system/GCounter.json new file mode 100644 index 000000000..84bc81af2 --- /dev/null +++ b/packages/spec/json-schema/system/GCounter.json @@ -0,0 +1,28 @@ +{ + "$ref": "#/definitions/GCounter", + "definitions": { + "GCounter": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "g-counter" + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to count" + } + }, + "required": [ + "type", + "counts" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/LWWRegister.json b/packages/spec/json-schema/system/LWWRegister.json new file mode 100644 index 000000000..df36e449d --- /dev/null +++ b/packages/spec/json-schema/system/LWWRegister.json @@ -0,0 +1,51 @@ +{ + "$ref": "#/definitions/LWWRegister", + "definitions": { + "LWWRegister": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lww-register" + }, + "value": { + "description": "Current register value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of last write" + }, + "replicaId": { + "type": "string", + "description": "ID of replica that performed last write" + }, + "vectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false, + "description": "Optional vector clock for causality tracking" + } + }, + "required": [ + "type", + "timestamp", + "replicaId" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/ORSet.json b/packages/spec/json-schema/system/ORSet.json new file mode 100644 index 000000000..5ac438d3d --- /dev/null +++ b/packages/spec/json-schema/system/ORSet.json @@ -0,0 +1,57 @@ +{ + "$ref": "#/definitions/ORSet", + "definitions": { + "ORSet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "or-set" + }, + "elements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "Element value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Addition timestamp" + }, + "replicaId": { + "type": "string", + "description": "Replica that added the element" + }, + "uid": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this addition" + }, + "removed": { + "type": "boolean", + "default": false, + "description": "Whether element has been removed" + } + }, + "required": [ + "timestamp", + "replicaId", + "uid" + ], + "additionalProperties": false + }, + "description": "Set elements with metadata" + } + }, + "required": [ + "type", + "elements" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/ORSetElement.json b/packages/spec/json-schema/system/ORSetElement.json new file mode 100644 index 000000000..6c40e698b --- /dev/null +++ b/packages/spec/json-schema/system/ORSetElement.json @@ -0,0 +1,39 @@ +{ + "$ref": "#/definitions/ORSetElement", + "definitions": { + "ORSetElement": { + "type": "object", + "properties": { + "value": { + "description": "Element value" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Addition timestamp" + }, + "replicaId": { + "type": "string", + "description": "Replica that added the element" + }, + "uid": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this addition" + }, + "removed": { + "type": "boolean", + "default": false, + "description": "Whether element has been removed" + } + }, + "required": [ + "timestamp", + "replicaId", + "uid" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/OTComponent.json b/packages/spec/json-schema/system/OTComponent.json new file mode 100644 index 000000000..164e148fc --- /dev/null +++ b/packages/spec/json-schema/system/OTComponent.json @@ -0,0 +1,76 @@ +{ + "$ref": "#/definitions/OTComponent", + "definitions": { + "OTComponent": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "insert" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Text formatting attributes (e.g., bold, italic)" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delete" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retain" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to retain" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Attribute changes to apply" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/OTOperation.json b/packages/spec/json-schema/system/OTOperation.json new file mode 100644 index 000000000..b650bc858 --- /dev/null +++ b/packages/spec/json-schema/system/OTOperation.json @@ -0,0 +1,128 @@ +{ + "$ref": "#/definitions/OTOperation", + "definitions": { + "OTOperation": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userId": { + "type": "string", + "description": "User who created the operation" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "components": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "insert" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Text formatting attributes (e.g., bold, italic)" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delete" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retain" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to retain" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Attribute changes to apply" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + } + ] + }, + "description": "Operation components" + }, + "baseVersion": { + "type": "integer", + "minimum": 0, + "description": "Document version this operation is based on" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when operation was created" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional operation metadata" + } + }, + "required": [ + "operationId", + "documentId", + "userId", + "sessionId", + "components", + "baseVersion", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/OTOperationType.json b/packages/spec/json-schema/system/OTOperationType.json new file mode 100644 index 000000000..38bdd8e49 --- /dev/null +++ b/packages/spec/json-schema/system/OTOperationType.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/OTOperationType", + "definitions": { + "OTOperationType": { + "type": "string", + "enum": [ + "insert", + "delete", + "retain" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/OTTransformResult.json b/packages/spec/json-schema/system/OTTransformResult.json new file mode 100644 index 000000000..593b076a3 --- /dev/null +++ b/packages/spec/json-schema/system/OTTransformResult.json @@ -0,0 +1,150 @@ +{ + "$ref": "#/definitions/OTTransformResult", + "definitions": { + "OTTransformResult": { + "type": "object", + "properties": { + "operation": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "userId": { + "type": "string", + "description": "User who created the operation" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Session identifier" + }, + "components": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "insert" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Text formatting attributes (e.g., bold, italic)" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delete" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retain" + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to retain" + }, + "attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Attribute changes to apply" + } + }, + "required": [ + "type", + "count" + ], + "additionalProperties": false + } + ] + }, + "description": "Operation components" + }, + "baseVersion": { + "type": "integer", + "minimum": 0, + "description": "Document version this operation is based on" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when operation was created" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Additional operation metadata" + } + }, + "required": [ + "operationId", + "documentId", + "userId", + "sessionId", + "components", + "baseVersion", + "timestamp" + ], + "additionalProperties": false, + "description": "Transformed operation" + }, + "transformed": { + "type": "boolean", + "description": "Whether transformation was applied" + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Conflict descriptions if any" + } + }, + "required": [ + "operation", + "transformed" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/PNCounter.json b/packages/spec/json-schema/system/PNCounter.json new file mode 100644 index 000000000..a33588423 --- /dev/null +++ b/packages/spec/json-schema/system/PNCounter.json @@ -0,0 +1,37 @@ +{ + "$ref": "#/definitions/PNCounter", + "definitions": { + "PNCounter": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pn-counter" + }, + "positive": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Positive increments per replica" + }, + "negative": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Negative increments per replica" + } + }, + "required": [ + "type", + "positive", + "negative" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/TextCRDTOperation.json b/packages/spec/json-schema/system/TextCRDTOperation.json new file mode 100644 index 000000000..3e91c3360 --- /dev/null +++ b/packages/spec/json-schema/system/TextCRDTOperation.json @@ -0,0 +1,52 @@ +{ + "$ref": "#/definitions/TextCRDTOperation", + "definitions": { + "TextCRDTOperation": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "replicaId": { + "type": "string", + "description": "Replica identifier" + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Position in document" + }, + "insert": { + "type": "string", + "description": "Text to insert" + }, + "delete": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of operation" + }, + "lamportTimestamp": { + "type": "integer", + "minimum": 0, + "description": "Lamport timestamp for ordering" + } + }, + "required": [ + "operationId", + "replicaId", + "position", + "timestamp", + "lamportTimestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/TextCRDTState.json b/packages/spec/json-schema/system/TextCRDTState.json new file mode 100644 index 000000000..28be84cae --- /dev/null +++ b/packages/spec/json-schema/system/TextCRDTState.json @@ -0,0 +1,105 @@ +{ + "$ref": "#/definitions/TextCRDTState", + "definitions": { + "TextCRDTState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "documentId": { + "type": "string", + "description": "Document identifier" + }, + "content": { + "type": "string", + "description": "Current text content" + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "format": "uuid", + "description": "Unique operation identifier" + }, + "replicaId": { + "type": "string", + "description": "Replica identifier" + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Position in document" + }, + "insert": { + "type": "string", + "description": "Text to insert" + }, + "delete": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Number of characters to delete" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime of operation" + }, + "lamportTimestamp": { + "type": "integer", + "minimum": 0, + "description": "Lamport timestamp for ordering" + } + }, + "required": [ + "operationId", + "replicaId", + "position", + "timestamp", + "lamportTimestamp" + ], + "additionalProperties": false + }, + "description": "History of operations" + }, + "lamportClock": { + "type": "integer", + "minimum": 0, + "description": "Current Lamport clock value" + }, + "vectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false, + "description": "Vector clock for causality" + } + }, + "required": [ + "type", + "documentId", + "content", + "operations", + "lamportClock", + "vectorClock" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/UserActivityStatus.json b/packages/spec/json-schema/system/UserActivityStatus.json new file mode 100644 index 000000000..89425cd95 --- /dev/null +++ b/packages/spec/json-schema/system/UserActivityStatus.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/UserActivityStatus", + "definitions": { + "UserActivityStatus": { + "type": "string", + "enum": [ + "active", + "idle", + "viewing", + "disconnected" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/VectorClock.json b/packages/spec/json-schema/system/VectorClock.json new file mode 100644 index 000000000..a57199880 --- /dev/null +++ b/packages/spec/json-schema/system/VectorClock.json @@ -0,0 +1,23 @@ +{ + "$ref": "#/definitions/VectorClock", + "definitions": { + "VectorClock": { + "type": "object", + "properties": { + "clock": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Map of replica ID to logical timestamp" + } + }, + "required": [ + "clock" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 3b754fe9b..7fda26d57 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -11,6 +11,7 @@ export * from './contract.zod'; export * from './endpoint.zod'; export * from './discovery.zod'; export * from './realtime.zod'; +export * from './websocket.zod'; export * from './router.zod'; export * from './odata.zod'; export * from './graphql.zod'; diff --git a/packages/spec/src/api/websocket.test.ts b/packages/spec/src/api/websocket.test.ts new file mode 100644 index 000000000..d59338d47 --- /dev/null +++ b/packages/spec/src/api/websocket.test.ts @@ -0,0 +1,829 @@ +import { describe, it, expect } from 'vitest'; +import { + WebSocketMessageType, + FilterOperator, + EventFilterCondition, + EventFilterSchema, + EventSubscriptionSchema, + UnsubscribeRequestSchema, + WebSocketPresenceStatus, + PresenceStateSchema, + PresenceUpdateSchema, + CursorPositionSchema, + EditOperationType, + EditOperationSchema, + DocumentStateSchema, + SubscribeMessageSchema, + UnsubscribeMessageSchema, + EventMessageSchema, + PresenceMessageSchema, + CursorMessageSchema, + EditMessageSchema, + AckMessageSchema, + ErrorMessageSchema, + PingMessageSchema, + PongMessageSchema, + WebSocketMessageSchema, + WebSocketConfigSchema, + type EventSubscription, + type PresenceState, + type CursorPosition, + type EditOperation, + type WebSocketMessage, +} from './websocket.zod'; + +describe('WebSocketMessageType', () => { + it('should accept valid message types', () => { + const validTypes = [ + 'subscribe', 'unsubscribe', 'event', 'ping', 'pong', + 'ack', 'error', 'presence', 'cursor', 'edit', + ]; + + validTypes.forEach(type => { + expect(() => WebSocketMessageType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid message types', () => { + expect(() => WebSocketMessageType.parse('invalid')).toThrow(); + expect(() => WebSocketMessageType.parse('')).toThrow(); + }); +}); + +describe('FilterOperator', () => { + it('should accept valid filter operators', () => { + const operators = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'contains', 'startsWith', 'endsWith', 'exists', 'regex']; + + operators.forEach(op => { + expect(() => FilterOperator.parse(op)).not.toThrow(); + }); + }); + + it('should reject invalid operators', () => { + expect(() => FilterOperator.parse('like')).toThrow(); + expect(() => FilterOperator.parse('between')).toThrow(); + }); +}); + +describe('EventFilterCondition', () => { + it('should accept valid filter condition', () => { + const condition = { + field: 'status', + operator: 'eq', + value: 'active', + }; + + expect(() => EventFilterCondition.parse(condition)).not.toThrow(); + }); + + it('should accept filter with dot notation field path', () => { + const condition = { + field: 'user.email', + operator: 'contains', + value: '@example.com', + }; + + const parsed = EventFilterCondition.parse(condition); + expect(parsed.field).toBe('user.email'); + }); + + it('should accept exists operator without value', () => { + const condition = { + field: 'optional_field', + operator: 'exists', + }; + + const parsed = EventFilterCondition.parse(condition); + expect(parsed.value).toBeUndefined(); + }); +}); + +describe('EventFilterSchema', () => { + it('should accept simple filter with conditions', () => { + const filter = { + conditions: [ + { field: 'status', operator: 'eq', value: 'active' }, + { field: 'amount', operator: 'gt', value: 1000 }, + ], + }; + + expect(() => EventFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept AND logical combination', () => { + const filter = { + and: [ + { conditions: [{ field: 'status', operator: 'eq', value: 'active' }] }, + { conditions: [{ field: 'verified', operator: 'eq', value: true }] }, + ], + }; + + expect(() => EventFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept OR logical combination', () => { + const filter = { + or: [ + { conditions: [{ field: 'type', operator: 'eq', value: 'urgent' }] }, + { conditions: [{ field: 'priority', operator: 'gte', value: 5 }] }, + ], + }; + + expect(() => EventFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept NOT logical negation', () => { + const filter = { + not: { + conditions: [{ field: 'deleted', operator: 'eq', value: true }], + }, + }; + + expect(() => EventFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept complex nested filters', () => { + const filter = { + and: [ + { conditions: [{ field: 'status', operator: 'eq', value: 'active' }] }, + { + or: [ + { conditions: [{ field: 'type', operator: 'eq', value: 'premium' }] }, + { conditions: [{ field: 'amount', operator: 'gte', value: 10000 }] }, + ], + }, + ], + }; + + expect(() => EventFilterSchema.parse(filter)).not.toThrow(); + }); +}); + +describe('EventSubscriptionSchema', () => { + it('should accept valid minimal subscription', () => { + const subscription: EventSubscription = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + events: ['record.created'], + }; + + expect(() => EventSubscriptionSchema.parse(subscription)).not.toThrow(); + }); + + it('should accept subscription with wildcard events', () => { + const subscription = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + events: ['record.*', 'user.created', '*.deleted'], + }; + + expect(() => EventSubscriptionSchema.parse(subscription)).not.toThrow(); + }); + + it('should accept subscription with objects filter', () => { + const subscription = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + events: ['record.updated'], + objects: ['account', 'contact'], + }; + + const parsed = EventSubscriptionSchema.parse(subscription); + expect(parsed.objects).toEqual(['account', 'contact']); + }); + + it('should accept subscription with advanced filters', () => { + const subscription = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + events: ['record.created'], + filters: { + conditions: [ + { field: 'amount', operator: 'gt', value: 5000 }, + ], + }, + }; + + expect(() => EventSubscriptionSchema.parse(subscription)).not.toThrow(); + }); + + it('should accept subscription with channels', () => { + const subscription = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + events: ['notification.*'], + channels: ['user-123', 'team-456'], + }; + + const parsed = EventSubscriptionSchema.parse(subscription); + expect(parsed.channels).toEqual(['user-123', 'team-456']); + }); + + it('should validate UUID format', () => { + expect(() => EventSubscriptionSchema.parse({ + subscriptionId: 'not-a-uuid', + events: ['record.created'], + })).toThrow(); + }); +}); + +describe('UnsubscribeRequestSchema', () => { + it('should accept valid unsubscribe request', () => { + const request = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + }; + + expect(() => UnsubscribeRequestSchema.parse(request)).not.toThrow(); + }); + + it('should validate UUID format', () => { + expect(() => UnsubscribeRequestSchema.parse({ + subscriptionId: 'invalid-uuid', + })).toThrow(); + }); +}); + +describe('WebSocketPresenceStatus', () => { + it('should accept valid presence statuses', () => { + const statuses = ['online', 'away', 'busy', 'offline']; + + statuses.forEach(status => { + expect(() => WebSocketPresenceStatus.parse(status)).not.toThrow(); + }); + }); + + it('should reject invalid statuses', () => { + expect(() => WebSocketPresenceStatus.parse('idle')).toThrow(); + expect(() => WebSocketPresenceStatus.parse('dnd')).toThrow(); + }); +}); + +describe('PresenceStateSchema', () => { + it('should accept valid minimal presence state', () => { + const presence: PresenceState = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + status: 'online', + lastSeen: '2024-01-15T10:30:00Z', + }; + + expect(() => PresenceStateSchema.parse(presence)).not.toThrow(); + }); + + it('should accept presence with all optional fields', () => { + const presence = { + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + status: 'busy', + lastSeen: '2024-01-15T10:30:00Z', + currentLocation: '/dashboard/analytics', + device: 'desktop', + customStatus: 'In a meeting', + metadata: { team: 'engineering', role: 'developer' }, + }; + + const parsed = PresenceStateSchema.parse(presence); + expect(parsed.currentLocation).toBe('/dashboard/analytics'); + expect(parsed.device).toBe('desktop'); + expect(parsed.customStatus).toBe('In a meeting'); + }); + + it('should validate device type', () => { + expect(() => PresenceStateSchema.parse({ + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + status: 'online', + lastSeen: '2024-01-15T10:30:00Z', + device: 'invalid-device', + })).toThrow(); + }); +}); + +describe('PresenceUpdateSchema', () => { + it('should accept partial presence updates', () => { + const update = { + status: 'away', + }; + + expect(() => PresenceUpdateSchema.parse(update)).not.toThrow(); + }); + + it('should accept multiple fields update', () => { + const update = { + status: 'online', + currentLocation: '/projects/123', + customStatus: 'Working on feature X', + }; + + expect(() => PresenceUpdateSchema.parse(update)).not.toThrow(); + }); +}); + +describe('CursorPositionSchema', () => { + it('should accept valid minimal cursor position', () => { + const cursor: CursorPosition = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + lastUpdate: '2024-01-15T10:30:00Z', + }; + + expect(() => CursorPositionSchema.parse(cursor)).not.toThrow(); + }); + + it('should accept cursor with position', () => { + const cursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + position: { line: 10, column: 25 }, + lastUpdate: '2024-01-15T10:30:00Z', + }; + + const parsed = CursorPositionSchema.parse(cursor); + expect(parsed.position?.line).toBe(10); + expect(parsed.position?.column).toBe(25); + }); + + it('should accept cursor with selection', () => { + const cursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + position: { line: 10, column: 0 }, + selection: { + start: { line: 10, column: 0 }, + end: { line: 15, column: 20 }, + }, + lastUpdate: '2024-01-15T10:30:00Z', + }; + + expect(() => CursorPositionSchema.parse(cursor)).not.toThrow(); + }); + + it('should accept cursor with color and userName', () => { + const cursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + color: '#FF5733', + userName: 'John Doe', + lastUpdate: '2024-01-15T10:30:00Z', + }; + + const parsed = CursorPositionSchema.parse(cursor); + expect(parsed.color).toBe('#FF5733'); + expect(parsed.userName).toBe('John Doe'); + }); + + it('should reject negative position values', () => { + expect(() => CursorPositionSchema.parse({ + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + position: { line: -1, column: 0 }, + lastUpdate: '2024-01-15T10:30:00Z', + })).toThrow(); + }); +}); + +describe('EditOperationType', () => { + it('should accept valid operation types', () => { + const types = ['insert', 'delete', 'replace']; + + types.forEach(type => { + expect(() => EditOperationType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid operation types', () => { + expect(() => EditOperationType.parse('update')).toThrow(); + }); +}); + +describe('EditOperationSchema', () => { + it('should accept valid insert operation', () => { + const operation: EditOperation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + type: 'insert', + position: { line: 5, column: 10 }, + content: 'Hello, World!', + version: 42, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => EditOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should accept delete operation', () => { + const operation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + type: 'delete', + position: { line: 5, column: 10 }, + endPosition: { line: 5, column: 25 }, + version: 42, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => EditOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should accept replace operation', () => { + const operation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + type: 'replace', + position: { line: 5, column: 10 }, + endPosition: { line: 5, column: 25 }, + content: 'New content', + version: 42, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => EditOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should accept operation with baseOperationId', () => { + const operation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + type: 'insert', + position: { line: 5, column: 10 }, + content: 'Text', + version: 42, + timestamp: '2024-01-15T10:30:00Z', + baseOperationId: '550e8400-e29b-41d4-a716-446655440002', + }; + + const parsed = EditOperationSchema.parse(operation); + expect(parsed.baseOperationId).toBe('550e8400-e29b-41d4-a716-446655440002'); + }); + + it('should reject negative version', () => { + expect(() => EditOperationSchema.parse({ + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + type: 'insert', + position: { line: 0, column: 0 }, + content: 'Text', + version: -1, + timestamp: '2024-01-15T10:30:00Z', + })).toThrow(); + }); +}); + +describe('DocumentStateSchema', () => { + it('should accept valid document state', () => { + const state = { + documentId: 'doc-123', + version: 42, + content: 'Document content here', + lastModified: '2024-01-15T10:30:00Z', + activeSessions: [ + '550e8400-e29b-41d4-a716-446655440000', + '550e8400-e29b-41d4-a716-446655440001', + ], + }; + + expect(() => DocumentStateSchema.parse(state)).not.toThrow(); + }); + + it('should accept document state with checksum', () => { + const state = { + documentId: 'doc-123', + version: 42, + content: 'Document content', + lastModified: '2024-01-15T10:30:00Z', + activeSessions: [], + checksum: 'sha256:abcdef1234567890', + }; + + const parsed = DocumentStateSchema.parse(state); + expect(parsed.checksum).toBe('sha256:abcdef1234567890'); + }); +}); + +describe('WebSocket Message Schemas', () => { + describe('SubscribeMessageSchema', () => { + it('should accept valid subscribe message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'subscribe', + timestamp: '2024-01-15T10:30:00Z', + subscription: { + subscriptionId: '550e8400-e29b-41d4-a716-446655440001', + events: ['record.created'], + }, + }; + + expect(() => SubscribeMessageSchema.parse(message)).not.toThrow(); + }); + }); + + describe('UnsubscribeMessageSchema', () => { + it('should accept valid unsubscribe message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'unsubscribe', + timestamp: '2024-01-15T10:30:00Z', + request: { + subscriptionId: '550e8400-e29b-41d4-a716-446655440001', + }, + }; + + expect(() => UnsubscribeMessageSchema.parse(message)).not.toThrow(); + }); + }); + + describe('EventMessageSchema', () => { + it('should accept valid event message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'event', + timestamp: '2024-01-15T10:30:00Z', + subscriptionId: '550e8400-e29b-41d4-a716-446655440001', + eventName: 'record.created', + payload: { id: '123', name: 'Test' }, + }; + + expect(() => EventMessageSchema.parse(message)).not.toThrow(); + }); + + it('should accept event message with object and userId', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'event', + timestamp: '2024-01-15T10:30:00Z', + subscriptionId: '550e8400-e29b-41d4-a716-446655440001', + eventName: 'record.updated', + object: 'account', + payload: { id: '123', status: 'active' }, + userId: 'user-456', + }; + + const parsed = EventMessageSchema.parse(message); + expect(parsed.object).toBe('account'); + expect(parsed.userId).toBe('user-456'); + }); + }); + + describe('PresenceMessageSchema', () => { + it('should accept valid presence message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'presence', + timestamp: '2024-01-15T10:30:00Z', + presence: { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + status: 'online', + lastSeen: '2024-01-15T10:30:00Z', + }, + }; + + expect(() => PresenceMessageSchema.parse(message)).not.toThrow(); + }); + }); + + describe('CursorMessageSchema', () => { + it('should accept valid cursor message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'cursor', + timestamp: '2024-01-15T10:30:00Z', + cursor: { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + documentId: 'doc-456', + position: { line: 10, column: 5 }, + lastUpdate: '2024-01-15T10:30:00Z', + }, + }; + + expect(() => CursorMessageSchema.parse(message)).not.toThrow(); + }); + }); + + describe('EditMessageSchema', () => { + it('should accept valid edit message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'edit', + timestamp: '2024-01-15T10:30:00Z', + operation: { + operationId: '550e8400-e29b-41d4-a716-446655440001', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440002', + type: 'insert', + position: { line: 5, column: 10 }, + content: 'Text', + version: 42, + timestamp: '2024-01-15T10:30:00Z', + }, + }; + + expect(() => EditMessageSchema.parse(message)).not.toThrow(); + }); + }); + + describe('AckMessageSchema', () => { + it('should accept valid acknowledgment message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'ack', + timestamp: '2024-01-15T10:30:00Z', + ackMessageId: '550e8400-e29b-41d4-a716-446655440001', + success: true, + }; + + expect(() => AckMessageSchema.parse(message)).not.toThrow(); + }); + + it('should accept acknowledgment with error', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'ack', + timestamp: '2024-01-15T10:30:00Z', + ackMessageId: '550e8400-e29b-41d4-a716-446655440001', + success: false, + error: 'Invalid subscription configuration', + }; + + const parsed = AckMessageSchema.parse(message); + expect(parsed.success).toBe(false); + expect(parsed.error).toBe('Invalid subscription configuration'); + }); + }); + + describe('ErrorMessageSchema', () => { + it('should accept valid error message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'error', + timestamp: '2024-01-15T10:30:00Z', + code: 'INVALID_SUBSCRIPTION', + message: 'Subscription configuration is invalid', + }; + + expect(() => ErrorMessageSchema.parse(message)).not.toThrow(); + }); + + it('should accept error message with details', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'error', + timestamp: '2024-01-15T10:30:00Z', + code: 'VALIDATION_ERROR', + message: 'Validation failed', + details: { field: 'events', reason: 'Array cannot be empty' }, + }; + + const parsed = ErrorMessageSchema.parse(message); + expect(parsed.details).toBeDefined(); + }); + }); + + describe('PingMessageSchema', () => { + it('should accept valid ping message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'ping', + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => PingMessageSchema.parse(message)).not.toThrow(); + }); + }); + + describe('PongMessageSchema', () => { + it('should accept valid pong message', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'pong', + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => PongMessageSchema.parse(message)).not.toThrow(); + }); + + it('should accept pong with pingMessageId', () => { + const message = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'pong', + timestamp: '2024-01-15T10:30:00Z', + pingMessageId: '550e8400-e29b-41d4-a716-446655440001', + }; + + const parsed = PongMessageSchema.parse(message); + expect(parsed.pingMessageId).toBe('550e8400-e29b-41d4-a716-446655440001'); + }); + }); + + describe('WebSocketMessageSchema (Union)', () => { + it('should accept all valid message types', () => { + const messages = [ + { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'ping', + timestamp: '2024-01-15T10:30:00Z', + }, + { + messageId: '550e8400-e29b-41d4-a716-446655440001', + type: 'error', + timestamp: '2024-01-15T10:30:00Z', + code: 'TEST_ERROR', + message: 'Test error message', + }, + ]; + + messages.forEach(msg => { + expect(() => WebSocketMessageSchema.parse(msg)).not.toThrow(); + }); + }); + + it('should use discriminated union on type field', () => { + const message: WebSocketMessage = { + messageId: '550e8400-e29b-41d4-a716-446655440000', + type: 'ping', + timestamp: '2024-01-15T10:30:00Z', + }; + + const parsed = WebSocketMessageSchema.parse(message); + expect(parsed.type).toBe('ping'); + }); + }); +}); + +describe('WebSocketConfigSchema', () => { + it('should accept valid minimal config', () => { + const config = { + url: 'wss://example.com/ws', + }; + + expect(() => WebSocketConfigSchema.parse(config)).not.toThrow(); + }); + + it('should accept config with all options', () => { + const config = { + url: 'wss://example.com/ws', + protocols: ['objectstack-v1', 'json'], + reconnect: true, + reconnectInterval: 2000, + maxReconnectAttempts: 10, + pingInterval: 60000, + timeout: 10000, + headers: { + 'Authorization': 'Bearer token123', + 'X-Custom-Header': 'value', + }, + }; + + const parsed = WebSocketConfigSchema.parse(config); + expect(parsed.reconnect).toBe(true); + expect(parsed.reconnectInterval).toBe(2000); + expect(parsed.maxReconnectAttempts).toBe(10); + }); + + it('should use default values for optional fields', () => { + const config = { + url: 'wss://example.com/ws', + }; + + const parsed = WebSocketConfigSchema.parse(config); + expect(parsed.reconnect).toBe(true); + expect(parsed.reconnectInterval).toBe(1000); + expect(parsed.maxReconnectAttempts).toBe(5); + expect(parsed.pingInterval).toBe(30000); + expect(parsed.timeout).toBe(5000); + }); + + it('should validate URL format', () => { + expect(() => WebSocketConfigSchema.parse({ + url: 'not-a-url', + })).toThrow(); + + expect(() => WebSocketConfigSchema.parse({ + url: 'wss://example.com/ws', + })).not.toThrow(); + }); + + it('should reject negative intervals', () => { + expect(() => WebSocketConfigSchema.parse({ + url: 'wss://example.com/ws', + reconnectInterval: -1000, + })).toThrow(); + + expect(() => WebSocketConfigSchema.parse({ + url: 'wss://example.com/ws', + pingInterval: 0, + })).toThrow(); + }); +}); diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts new file mode 100644 index 000000000..f0e46a005 --- /dev/null +++ b/packages/spec/src/api/websocket.zod.ts @@ -0,0 +1,433 @@ +import { z } from 'zod'; +import { EventNameSchema } from '../shared/identifiers.zod'; + +/** + * WebSocket Event Protocol + * + * Defines the schema for WebSocket-based real-time communication in ObjectStack. + * Supports event subscriptions, filtering, presence tracking, and collaborative editing. + * + * Industry alignment: Firebase Realtime Database, Socket.IO, Pusher + */ + +// ========================================== +// Message Types +// ========================================== + +/** + * WebSocket Message Type Enum + * Defines the types of messages that can be sent over WebSocket + */ +export const WebSocketMessageType = z.enum([ + 'subscribe', // Client subscribes to events + 'unsubscribe', // Client unsubscribes from events + 'event', // Server sends event to client + 'ping', // Keepalive ping + 'pong', // Keepalive pong response + 'ack', // Acknowledgment of message receipt + 'error', // Error message + 'presence', // Presence update (user status) + 'cursor', // Cursor position update (collaborative editing) + 'edit', // Document edit operation (collaborative editing) +]); + +export type WebSocketMessageType = z.infer; + +// ========================================== +// Event Subscription +// ========================================== + +/** + * Event Filter Operator Enum + * SQL-like filter operators for event filtering + */ +export const FilterOperator = z.enum([ + 'eq', // Equal + 'ne', // Not equal + 'gt', // Greater than + 'gte', // Greater than or equal + 'lt', // Less than + 'lte', // Less than or equal + 'in', // In array + 'nin', // Not in array + 'contains', // String contains + 'startsWith', // String starts with + 'endsWith', // String ends with + 'exists', // Field exists + 'regex', // Regex match +]); + +export type FilterOperator = z.infer; + +/** + * Event Filter Condition + * Defines a single filter condition for event filtering + */ +export const EventFilterCondition = z.object({ + field: z.string().describe('Field path to filter on (supports dot notation, e.g., "user.email")'), + operator: FilterOperator.describe('Comparison operator'), + value: z.any().optional().describe('Value to compare against (not needed for "exists" operator)'), +}); + +export type EventFilterCondition = z.infer; + +/** + * Event Filter Schema + * Logical combination of filter conditions + */ +export const EventFilterSchema: z.ZodType<{ + conditions?: EventFilterCondition[]; + and?: EventFilter[]; + or?: EventFilter[]; + not?: EventFilter; +}> = z.object({ + conditions: z.array(EventFilterCondition).optional().describe('Array of filter conditions'), + and: z.lazy(() => z.array(EventFilterSchema)).optional().describe('AND logical combination of filters'), + or: z.lazy(() => z.array(EventFilterSchema)).optional().describe('OR logical combination of filters'), + not: z.lazy(() => EventFilterSchema).optional().describe('NOT logical negation of filter'), +}); + +export type EventFilter = z.infer; + +/** + * Event Pattern Schema + * Event name pattern that supports wildcards for subscriptions + */ +export const EventPatternSchema = z + .string() + .min(1) + .regex(/^[a-z*][a-z0-9_.*]*$/, { + message: 'Event pattern must be lowercase and may contain letters, numbers, underscores, dots, or wildcards (e.g., "record.*", "*.created", "user.login")', + }) + .describe('Event pattern (supports wildcards like "record.*" or "*.created")'); + +export type EventPattern = z.infer; + +/** + * Event Subscription Config + * Configuration for subscribing to specific events + */ +export const EventSubscriptionSchema = z.object({ + subscriptionId: z.string().uuid().describe('Unique subscription identifier'), + events: z.array(EventPatternSchema).describe('Event patterns to subscribe to (supports wildcards, e.g., "record.*", "user.created")'), + objects: z.array(z.string()).optional().describe('Object names to filter events by (e.g., ["account", "contact"])'), + filters: EventFilterSchema.optional().describe('Advanced filter conditions for event payloads'), + channels: z.array(z.string()).optional().describe('Channel names for scoped subscriptions'), +}); + +export type EventSubscription = z.infer; + +/** + * Unsubscribe Request + * Request to unsubscribe from events + */ +export const UnsubscribeRequestSchema = z.object({ + subscriptionId: z.string().uuid().describe('Subscription ID to unsubscribe from'), +}); + +export type UnsubscribeRequest = z.infer; + +// ========================================== +// Presence Tracking +// ========================================== + +/** + * Presence Status Enum + * User availability status for presence tracking + */ +export const WebSocketPresenceStatus = z.enum([ + 'online', // User is actively online + 'away', // User is idle/away + 'busy', // User is busy (do not disturb) + 'offline', // User is offline +]); + +export type WebSocketPresenceStatus = z.infer; + +/** + * Presence State Schema + * Tracks real-time user presence and activity + */ +export const PresenceStateSchema = z.object({ + userId: z.string().describe('User identifier'), + sessionId: z.string().uuid().describe('Unique session identifier'), + status: WebSocketPresenceStatus.describe('Current presence status'), + lastSeen: z.string().datetime().describe('ISO 8601 datetime of last activity'), + currentLocation: z.string().optional().describe('Current page/route user is viewing'), + device: z.enum(['desktop', 'mobile', 'tablet', 'other']).optional().describe('Device type'), + customStatus: z.string().optional().describe('Custom user status message'), + metadata: z.record(z.any()).optional().describe('Additional custom presence data'), +}); + +export type PresenceState = z.infer; + +/** + * Presence Update Request + * Client request to update presence status + */ +export const PresenceUpdateSchema = z.object({ + status: WebSocketPresenceStatus.optional().describe('Updated presence status'), + currentLocation: z.string().optional().describe('Updated current location'), + customStatus: z.string().optional().describe('Updated custom status message'), + metadata: z.record(z.any()).optional().describe('Updated metadata'), +}); + +export type PresenceUpdate = z.infer; + +// ========================================== +// Collaborative Editing Protocol +// ========================================== + +/** + * Cursor Position Schema + * Represents a cursor position in a document + */ +export const CursorPositionSchema = z.object({ + userId: z.string().describe('User identifier'), + sessionId: z.string().uuid().describe('Session identifier'), + documentId: z.string().describe('Document identifier being edited'), + position: z.object({ + line: z.number().int().nonnegative().describe('Line number (0-indexed)'), + column: z.number().int().nonnegative().describe('Column number (0-indexed)'), + }).optional().describe('Cursor position in document'), + selection: z.object({ + start: z.object({ + line: z.number().int().nonnegative(), + column: z.number().int().nonnegative(), + }), + end: z.object({ + line: z.number().int().nonnegative(), + column: z.number().int().nonnegative(), + }), + }).optional().describe('Selection range (if text is selected)'), + color: z.string().optional().describe('Cursor color for visual representation'), + userName: z.string().optional().describe('Display name of user'), + lastUpdate: z.string().datetime().describe('ISO 8601 datetime of last cursor update'), +}); + +export type CursorPosition = z.infer; + +/** + * Edit Operation Type Enum + * Types of edit operations for collaborative editing + */ +export const EditOperationType = z.enum([ + 'insert', // Insert text at position + 'delete', // Delete text from range + 'replace', // Replace text in range +]); + +export type EditOperationType = z.infer; + +/** + * Edit Operation Schema + * Represents a single edit operation on a document + * Supports Operational Transformation (OT) for conflict resolution + */ +export const EditOperationSchema = z.object({ + operationId: z.string().uuid().describe('Unique operation identifier'), + documentId: z.string().describe('Document identifier'), + userId: z.string().describe('User who performed the edit'), + sessionId: z.string().uuid().describe('Session identifier'), + type: EditOperationType.describe('Type of edit operation'), + position: z.object({ + line: z.number().int().nonnegative().describe('Line number (0-indexed)'), + column: z.number().int().nonnegative().describe('Column number (0-indexed)'), + }).describe('Starting position of the operation'), + endPosition: z.object({ + line: z.number().int().nonnegative(), + column: z.number().int().nonnegative(), + }).optional().describe('Ending position (for delete/replace operations)'), + content: z.string().optional().describe('Content to insert/replace'), + version: z.number().int().nonnegative().describe('Document version before this operation'), + timestamp: z.string().datetime().describe('ISO 8601 datetime when operation was created'), + baseOperationId: z.string().uuid().optional().describe('Previous operation ID this builds upon (for OT)'), +}); + +export type EditOperation = z.infer; + +/** + * Document State Schema + * Represents the current state of a collaborative document + */ +export const DocumentStateSchema = z.object({ + documentId: z.string().describe('Document identifier'), + version: z.number().int().nonnegative().describe('Current document version'), + content: z.string().describe('Current document content'), + lastModified: z.string().datetime().describe('ISO 8601 datetime of last modification'), + activeSessions: z.array(z.string().uuid()).describe('Active editing session IDs'), + checksum: z.string().optional().describe('Content checksum for integrity verification'), +}); + +export type DocumentState = z.infer; + +// ========================================== +// WebSocket Messages +// ========================================== + +/** + * Base WebSocket Message + * All WebSocket messages extend this base structure + */ +const BaseWebSocketMessage = z.object({ + messageId: z.string().uuid().describe('Unique message identifier'), + type: WebSocketMessageType.describe('Message type'), + timestamp: z.string().datetime().describe('ISO 8601 datetime when message was sent'), +}); + +/** + * Subscribe Message + * Client sends this to subscribe to events + */ +export const SubscribeMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('subscribe'), + subscription: EventSubscriptionSchema.describe('Subscription configuration'), +}); + +export type SubscribeMessage = z.infer; + +/** + * Unsubscribe Message + * Client sends this to unsubscribe from events + */ +export const UnsubscribeMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('unsubscribe'), + request: UnsubscribeRequestSchema.describe('Unsubscribe request'), +}); + +export type UnsubscribeMessage = z.infer; + +/** + * Event Message + * Server sends this when a subscribed event occurs + */ +export const EventMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('event'), + subscriptionId: z.string().uuid().describe('Subscription ID this event belongs to'), + eventName: EventNameSchema.describe('Event name'), + object: z.string().optional().describe('Object name the event relates to'), + payload: z.any().describe('Event payload data'), + userId: z.string().optional().describe('User who triggered the event'), +}); + +export type EventMessage = z.infer; + +/** + * Presence Message + * Presence update message + */ +export const PresenceMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('presence'), + presence: PresenceStateSchema.describe('Presence state'), +}); + +export type PresenceMessage = z.infer; + +/** + * Cursor Message + * Cursor position update for collaborative editing + */ +export const CursorMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('cursor'), + cursor: CursorPositionSchema.describe('Cursor position'), +}); + +export type CursorMessage = z.infer; + +/** + * Edit Message + * Document edit operation for collaborative editing + */ +export const EditMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('edit'), + operation: EditOperationSchema.describe('Edit operation'), +}); + +export type EditMessage = z.infer; + +/** + * Acknowledgment Message + * Server acknowledges receipt of a message + */ +export const AckMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('ack'), + ackMessageId: z.string().uuid().describe('ID of the message being acknowledged'), + success: z.boolean().describe('Whether the operation was successful'), + error: z.string().optional().describe('Error message if operation failed'), +}); + +export type AckMessage = z.infer; + +/** + * Error Message + * Server sends error information + */ +export const ErrorMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('error'), + code: z.string().describe('Error code'), + message: z.string().describe('Error message'), + details: z.any().optional().describe('Additional error details'), +}); + +export type ErrorMessage = z.infer; + +/** + * Ping Message + * Keepalive ping from client or server + */ +export const PingMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('ping'), +}); + +export type PingMessage = z.infer; + +/** + * Pong Message + * Keepalive pong response + */ +export const PongMessageSchema = BaseWebSocketMessage.extend({ + type: z.literal('pong'), + pingMessageId: z.string().uuid().optional().describe('ID of ping message being responded to'), +}); + +export type PongMessage = z.infer; + +/** + * WebSocket Message Union + * Discriminated union of all WebSocket message types + */ +export const WebSocketMessageSchema = z.discriminatedUnion('type', [ + SubscribeMessageSchema, + UnsubscribeMessageSchema, + EventMessageSchema, + PresenceMessageSchema, + CursorMessageSchema, + EditMessageSchema, + AckMessageSchema, + ErrorMessageSchema, + PingMessageSchema, + PongMessageSchema, +]); + +export type WebSocketMessage = z.infer; + +// ========================================== +// Connection Configuration +// ========================================== + +/** + * WebSocket Connection Config + * Configuration for WebSocket connections + */ +export const WebSocketConfigSchema = z.object({ + url: z.string().url().describe('WebSocket server URL'), + protocols: z.array(z.string()).optional().describe('WebSocket sub-protocols'), + reconnect: z.boolean().optional().default(true).describe('Enable automatic reconnection'), + reconnectInterval: z.number().int().positive().optional().default(1000).describe('Reconnection interval in milliseconds'), + maxReconnectAttempts: z.number().int().positive().optional().default(5).describe('Maximum reconnection attempts'), + pingInterval: z.number().int().positive().optional().default(30000).describe('Ping interval in milliseconds'), + timeout: z.number().int().positive().optional().default(5000).describe('Message timeout in milliseconds'), + headers: z.record(z.string()).optional().describe('Custom headers for WebSocket handshake'), +}); + +export type WebSocketConfig = z.infer; diff --git a/packages/spec/src/system/collaboration.test.ts b/packages/spec/src/system/collaboration.test.ts new file mode 100644 index 000000000..2bcc25bf0 --- /dev/null +++ b/packages/spec/src/system/collaboration.test.ts @@ -0,0 +1,999 @@ +import { describe, it, expect } from 'vitest'; +import { + OTOperationType, + OTComponentSchema, + OTOperationSchema, + OTTransformResultSchema, + CRDTType, + VectorClockSchema, + LWWRegisterSchema, + CounterOperationSchema, + GCounterSchema, + PNCounterSchema, + ORSetElementSchema, + ORSetSchema, + TextCRDTOperationSchema, + TextCRDTStateSchema, + CRDTStateSchema, + CRDTMergeResultSchema, + CursorColorPreset, + CursorStyleSchema, + CursorSelectionSchema, + CollaborativeCursorSchema, + CursorUpdateSchema, + UserActivityStatus, + AwarenessUserStateSchema, + AwarenessSessionSchema, + AwarenessUpdateSchema, + AwarenessEventSchema, + CollaborationMode, + CollaborationSessionConfigSchema, + CollaborationSessionSchema, + type OTOperation, + type LWWRegister, + type GCounter, + type PNCounter, + type ORSet, + type TextCRDTState, + type CollaborativeCursor, + type AwarenessUserState, + type CollaborationSession, +} from './collaboration.zod'; + +describe('OTOperationType', () => { + it('should accept valid OT operation types', () => { + const types = ['insert', 'delete', 'retain']; + + types.forEach(type => { + expect(() => OTOperationType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid operation types', () => { + expect(() => OTOperationType.parse('replace')).toThrow(); + expect(() => OTOperationType.parse('update')).toThrow(); + }); +}); + +describe('OTComponentSchema', () => { + it('should accept insert component', () => { + const component = { + type: 'insert', + text: 'Hello, World!', + }; + + expect(() => OTComponentSchema.parse(component)).not.toThrow(); + }); + + it('should accept insert with attributes', () => { + const component = { + type: 'insert', + text: 'Bold text', + attributes: { bold: true, fontSize: 14 }, + }; + + const parsed = OTComponentSchema.parse(component); + expect(parsed.attributes).toBeDefined(); + }); + + it('should accept delete component', () => { + const component = { + type: 'delete', + count: 10, + }; + + expect(() => OTComponentSchema.parse(component)).not.toThrow(); + }); + + it('should accept retain component', () => { + const component = { + type: 'retain', + count: 15, + }; + + expect(() => OTComponentSchema.parse(component)).not.toThrow(); + }); + + it('should accept retain with attributes', () => { + const component = { + type: 'retain', + count: 20, + attributes: { italic: true }, + }; + + const parsed = OTComponentSchema.parse(component); + expect(parsed.attributes).toBeDefined(); + }); + + it('should reject negative count', () => { + expect(() => OTComponentSchema.parse({ + type: 'delete', + count: -5, + })).toThrow(); + + expect(() => OTComponentSchema.parse({ + type: 'retain', + count: 0, + })).toThrow(); + }); +}); + +describe('OTOperationSchema', () => { + it('should accept valid OT operation', () => { + const operation: OTOperation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + components: [ + { type: 'retain', count: 10 }, + { type: 'insert', text: 'New text' }, + { type: 'retain', count: 5 }, + ], + baseVersion: 42, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => OTOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should accept operation with metadata', () => { + const operation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + components: [{ type: 'insert', text: 'Text' }], + baseVersion: 10, + timestamp: '2024-01-15T10:30:00Z', + metadata: { source: 'keyboard', device: 'desktop' }, + }; + + const parsed = OTOperationSchema.parse(operation); + expect(parsed.metadata).toBeDefined(); + }); + + it('should reject negative baseVersion', () => { + expect(() => OTOperationSchema.parse({ + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + components: [{ type: 'insert', text: 'Text' }], + baseVersion: -1, + timestamp: '2024-01-15T10:30:00Z', + })).toThrow(); + }); +}); + +describe('OTTransformResultSchema', () => { + it('should accept valid transform result', () => { + const result = { + operation: { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + components: [{ type: 'insert', text: 'Transformed' }], + baseVersion: 43, + timestamp: '2024-01-15T10:30:00Z', + }, + transformed: true, + }; + + expect(() => OTTransformResultSchema.parse(result)).not.toThrow(); + }); + + it('should accept result with conflicts', () => { + const result = { + operation: { + operationId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + components: [{ type: 'insert', text: 'Text' }], + baseVersion: 42, + timestamp: '2024-01-15T10:30:00Z', + }, + transformed: true, + conflicts: ['Overlapping edits detected', 'Position adjusted'], + }; + + const parsed = OTTransformResultSchema.parse(result); + expect(parsed.conflicts).toHaveLength(2); + }); +}); + +describe('CRDTType', () => { + it('should accept valid CRDT types', () => { + const types = [ + 'lww-register', 'g-counter', 'pn-counter', 'g-set', + 'or-set', 'lww-map', 'text', 'tree', 'json', + ]; + + types.forEach(type => { + expect(() => CRDTType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid CRDT types', () => { + expect(() => CRDTType.parse('list')).toThrow(); + expect(() => CRDTType.parse('vector')).toThrow(); + }); +}); + +describe('VectorClockSchema', () => { + it('should accept valid vector clock', () => { + const clock = { + clock: { + 'replica-1': 5, + 'replica-2': 3, + 'replica-3': 7, + }, + }; + + expect(() => VectorClockSchema.parse(clock)).not.toThrow(); + }); + + it('should reject negative timestamps', () => { + expect(() => VectorClockSchema.parse({ + clock: { 'replica-1': -1 }, + })).toThrow(); + }); +}); + +describe('LWWRegisterSchema', () => { + it('should accept valid LWW register', () => { + const register: LWWRegister = { + type: 'lww-register', + value: 'Current value', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + }; + + expect(() => LWWRegisterSchema.parse(register)).not.toThrow(); + }); + + it('should accept register with vector clock', () => { + const register = { + type: 'lww-register', + value: { data: 'object value' }, + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-2', + vectorClock: { + clock: { 'replica-1': 3, 'replica-2': 5 }, + }, + }; + + const parsed = LWWRegisterSchema.parse(register); + expect(parsed.vectorClock).toBeDefined(); + }); +}); + +describe('CounterOperationSchema', () => { + it('should accept increment operation', () => { + const operation = { + replicaId: 'replica-1', + delta: 5, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => CounterOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should accept decrement operation', () => { + const operation = { + replicaId: 'replica-1', + delta: -3, + timestamp: '2024-01-15T10:30:00Z', + }; + + expect(() => CounterOperationSchema.parse(operation)).not.toThrow(); + }); +}); + +describe('GCounterSchema', () => { + it('should accept valid G-Counter', () => { + const counter: GCounter = { + type: 'g-counter', + counts: { + 'replica-1': 10, + 'replica-2': 5, + 'replica-3': 3, + }, + }; + + expect(() => GCounterSchema.parse(counter)).not.toThrow(); + }); + + it('should reject negative counts', () => { + expect(() => GCounterSchema.parse({ + type: 'g-counter', + counts: { 'replica-1': -5 }, + })).toThrow(); + }); +}); + +describe('PNCounterSchema', () => { + it('should accept valid PN-Counter', () => { + const counter: PNCounter = { + type: 'pn-counter', + positive: { 'replica-1': 10, 'replica-2': 5 }, + negative: { 'replica-1': 3, 'replica-2': 2 }, + }; + + expect(() => PNCounterSchema.parse(counter)).not.toThrow(); + }); + + it('should reject negative values in positive counts', () => { + expect(() => PNCounterSchema.parse({ + type: 'pn-counter', + positive: { 'replica-1': -10 }, + negative: { 'replica-1': 0 }, + })).toThrow(); + }); +}); + +describe('ORSetElementSchema', () => { + it('should accept valid OR-Set element', () => { + const element = { + value: 'item-1', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + uid: '550e8400-e29b-41d4-a716-446655440000', + }; + + expect(() => ORSetElementSchema.parse(element)).not.toThrow(); + }); + + it('should accept removed element', () => { + const element = { + value: 'item-2', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + uid: '550e8400-e29b-41d4-a716-446655440000', + removed: true, + }; + + const parsed = ORSetElementSchema.parse(element); + expect(parsed.removed).toBe(true); + }); + + it('should use default false for removed', () => { + const element = { + value: 'item-3', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + uid: '550e8400-e29b-41d4-a716-446655440000', + }; + + const parsed = ORSetElementSchema.parse(element); + expect(parsed.removed).toBe(false); + }); +}); + +describe('ORSetSchema', () => { + it('should accept valid OR-Set', () => { + const set: ORSet = { + type: 'or-set', + elements: [ + { + value: 'item-1', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + uid: '550e8400-e29b-41d4-a716-446655440000', + }, + { + value: 'item-2', + timestamp: '2024-01-15T10:31:00Z', + replicaId: 'replica-2', + uid: '550e8400-e29b-41d4-a716-446655440001', + }, + ], + }; + + expect(() => ORSetSchema.parse(set)).not.toThrow(); + }); +}); + +describe('TextCRDTOperationSchema', () => { + it('should accept insert operation', () => { + const operation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + replicaId: 'replica-1', + position: 10, + insert: 'New text', + timestamp: '2024-01-15T10:30:00Z', + lamportTimestamp: 42, + }; + + expect(() => TextCRDTOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should accept delete operation', () => { + const operation = { + operationId: '550e8400-e29b-41d4-a716-446655440000', + replicaId: 'replica-1', + position: 5, + delete: 10, + timestamp: '2024-01-15T10:30:00Z', + lamportTimestamp: 43, + }; + + expect(() => TextCRDTOperationSchema.parse(operation)).not.toThrow(); + }); + + it('should reject negative position', () => { + expect(() => TextCRDTOperationSchema.parse({ + operationId: '550e8400-e29b-41d4-a716-446655440000', + replicaId: 'replica-1', + position: -1, + insert: 'Text', + timestamp: '2024-01-15T10:30:00Z', + lamportTimestamp: 42, + })).toThrow(); + }); +}); + +describe('TextCRDTStateSchema', () => { + it('should accept valid text CRDT state', () => { + const state: TextCRDTState = { + type: 'text', + documentId: 'doc-123', + content: 'Current document content', + operations: [ + { + operationId: '550e8400-e29b-41d4-a716-446655440000', + replicaId: 'replica-1', + position: 0, + insert: 'Initial text', + timestamp: '2024-01-15T10:30:00Z', + lamportTimestamp: 1, + }, + ], + lamportClock: 1, + vectorClock: { + clock: { 'replica-1': 1 }, + }, + }; + + expect(() => TextCRDTStateSchema.parse(state)).not.toThrow(); + }); +}); + +describe('CRDTStateSchema', () => { + it('should accept all CRDT types', () => { + const states = [ + { + type: 'lww-register', + value: 'test', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + }, + { + type: 'g-counter', + counts: { 'replica-1': 5 }, + }, + { + type: 'pn-counter', + positive: { 'replica-1': 10 }, + negative: { 'replica-1': 3 }, + }, + { + type: 'or-set', + elements: [], + }, + { + type: 'text', + documentId: 'doc-123', + content: 'Text', + operations: [], + lamportClock: 0, + vectorClock: { clock: {} }, + }, + ]; + + states.forEach(state => { + expect(() => CRDTStateSchema.parse(state)).not.toThrow(); + }); + }); + + it('should use discriminated union on type field', () => { + const state = { + type: 'g-counter', + counts: { 'replica-1': 5 }, + }; + + const parsed = CRDTStateSchema.parse(state); + expect(parsed.type).toBe('g-counter'); + }); +}); + +describe('CRDTMergeResultSchema', () => { + it('should accept merge result without conflicts', () => { + const result = { + state: { + type: 'lww-register', + value: 'merged value', + timestamp: '2024-01-15T10:30:00Z', + replicaId: 'replica-1', + }, + }; + + expect(() => CRDTMergeResultSchema.parse(result)).not.toThrow(); + }); + + it('should accept merge result with conflicts', () => { + const result = { + state: { + type: 'g-counter', + counts: { 'replica-1': 10 }, + }, + conflicts: [ + { + type: 'concurrent-update', + description: 'Concurrent updates detected', + resolved: true, + }, + ], + }; + + const parsed = CRDTMergeResultSchema.parse(result); + expect(parsed.conflicts).toHaveLength(1); + }); +}); + +describe('CursorColorPreset', () => { + it('should accept valid color presets', () => { + const colors = [ + 'blue', 'green', 'red', 'yellow', 'purple', + 'orange', 'pink', 'teal', 'indigo', 'cyan', + ]; + + colors.forEach(color => { + expect(() => CursorColorPreset.parse(color)).not.toThrow(); + }); + }); + + it('should reject invalid presets', () => { + expect(() => CursorColorPreset.parse('black')).toThrow(); + expect(() => CursorColorPreset.parse('white')).toThrow(); + }); +}); + +describe('CursorStyleSchema', () => { + it('should accept cursor style with preset color', () => { + const style = { + color: 'blue', + }; + + expect(() => CursorStyleSchema.parse(style)).not.toThrow(); + }); + + it('should accept cursor style with custom hex color', () => { + const style = { + color: '#FF5733', + }; + + expect(() => CursorStyleSchema.parse(style)).not.toThrow(); + }); + + it('should accept cursor style with all options', () => { + const style = { + color: 'green', + opacity: 0.8, + label: 'John Doe', + showLabel: true, + pulseOnUpdate: false, + }; + + const parsed = CursorStyleSchema.parse(style); + expect(parsed.opacity).toBe(0.8); + expect(parsed.showLabel).toBe(true); + expect(parsed.pulseOnUpdate).toBe(false); + }); + + it('should use default values', () => { + const style = { color: 'red' }; + + const parsed = CursorStyleSchema.parse(style); + expect(parsed.opacity).toBe(1); + expect(parsed.showLabel).toBe(true); + expect(parsed.pulseOnUpdate).toBe(true); + }); + + it('should reject opacity outside range', () => { + expect(() => CursorStyleSchema.parse({ + color: 'blue', + opacity: 1.5, + })).toThrow(); + + expect(() => CursorStyleSchema.parse({ + color: 'blue', + opacity: -0.1, + })).toThrow(); + }); +}); + +describe('CursorSelectionSchema', () => { + it('should accept valid cursor selection', () => { + const selection = { + anchor: { line: 5, column: 10 }, + focus: { line: 8, column: 20 }, + }; + + expect(() => CursorSelectionSchema.parse(selection)).not.toThrow(); + }); + + it('should accept selection with direction', () => { + const selection = { + anchor: { line: 5, column: 10 }, + focus: { line: 8, column: 20 }, + direction: 'forward', + }; + + const parsed = CursorSelectionSchema.parse(selection); + expect(parsed.direction).toBe('forward'); + }); + + it('should reject negative positions', () => { + expect(() => CursorSelectionSchema.parse({ + anchor: { line: -1, column: 0 }, + focus: { line: 5, column: 10 }, + })).toThrow(); + }); +}); + +describe('CollaborativeCursorSchema', () => { + it('should accept valid collaborative cursor', () => { + const cursor: CollaborativeCursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + userName: 'John Doe', + position: { line: 10, column: 5 }, + style: { color: 'blue' }, + lastUpdate: '2024-01-15T10:30:00Z', + }; + + expect(() => CollaborativeCursorSchema.parse(cursor)).not.toThrow(); + }); + + it('should accept cursor with selection', () => { + const cursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + userName: 'Jane Doe', + position: { line: 5, column: 0 }, + selection: { + anchor: { line: 5, column: 0 }, + focus: { line: 10, column: 20 }, + }, + style: { color: 'green' }, + lastUpdate: '2024-01-15T10:30:00Z', + }; + + const parsed = CollaborativeCursorSchema.parse(cursor); + expect(parsed.selection).toBeDefined(); + }); + + it('should accept cursor with isTyping flag', () => { + const cursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + userName: 'Bob Smith', + position: { line: 15, column: 30 }, + style: { color: 'red' }, + isTyping: true, + lastUpdate: '2024-01-15T10:30:00Z', + }; + + const parsed = CollaborativeCursorSchema.parse(cursor); + expect(parsed.isTyping).toBe(true); + }); + + it('should use default false for isTyping', () => { + const cursor = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-456', + userName: 'Alice', + position: { line: 0, column: 0 }, + style: { color: 'purple' }, + lastUpdate: '2024-01-15T10:30:00Z', + }; + + const parsed = CollaborativeCursorSchema.parse(cursor); + expect(parsed.isTyping).toBe(false); + }); +}); + +describe('CursorUpdateSchema', () => { + it('should accept position update', () => { + const update = { + position: { line: 20, column: 15 }, + }; + + expect(() => CursorUpdateSchema.parse(update)).not.toThrow(); + }); + + it('should accept multiple field updates', () => { + const update = { + position: { line: 10, column: 5 }, + isTyping: true, + metadata: { tool: 'keyboard' }, + }; + + expect(() => CursorUpdateSchema.parse(update)).not.toThrow(); + }); +}); + +describe('UserActivityStatus', () => { + it('should accept valid activity statuses', () => { + const statuses = ['active', 'idle', 'viewing', 'disconnected']; + + statuses.forEach(status => { + expect(() => UserActivityStatus.parse(status)).not.toThrow(); + }); + }); + + it('should reject invalid statuses', () => { + expect(() => UserActivityStatus.parse('offline')).toThrow(); + expect(() => UserActivityStatus.parse('away')).toThrow(); + }); +}); + +describe('AwarenessUserStateSchema', () => { + it('should accept valid user state', () => { + const userState: AwarenessUserState = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + userName: 'John Doe', + status: 'active', + lastActivity: '2024-01-15T10:30:00Z', + joinedAt: '2024-01-15T10:00:00Z', + }; + + expect(() => AwarenessUserStateSchema.parse(userState)).not.toThrow(); + }); + + it('should accept user state with all optional fields', () => { + const userState = { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440000', + userName: 'Jane Doe', + userAvatar: 'https://example.com/avatar.jpg', + status: 'viewing', + currentDocument: 'doc-456', + currentView: '/editor', + lastActivity: '2024-01-15T10:30:00Z', + joinedAt: '2024-01-15T10:00:00Z', + permissions: ['read', 'write', 'comment'], + metadata: { role: 'editor', team: 'engineering' }, + }; + + const parsed = AwarenessUserStateSchema.parse(userState); + expect(parsed.currentDocument).toBe('doc-456'); + expect(parsed.permissions).toEqual(['read', 'write', 'comment']); + }); +}); + +describe('AwarenessSessionSchema', () => { + it('should accept valid awareness session', () => { + const session = { + sessionId: '550e8400-e29b-41d4-a716-446655440000', + users: [ + { + userId: 'user-123', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + userName: 'User 1', + status: 'active', + lastActivity: '2024-01-15T10:30:00Z', + joinedAt: '2024-01-15T10:00:00Z', + }, + ], + startedAt: '2024-01-15T10:00:00Z', + lastUpdate: '2024-01-15T10:30:00Z', + }; + + expect(() => AwarenessSessionSchema.parse(session)).not.toThrow(); + }); + + it('should accept session with documentId', () => { + const session = { + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + users: [], + startedAt: '2024-01-15T10:00:00Z', + lastUpdate: '2024-01-15T10:30:00Z', + }; + + const parsed = AwarenessSessionSchema.parse(session); + expect(parsed.documentId).toBe('doc-123'); + }); +}); + +describe('AwarenessUpdateSchema', () => { + it('should accept status update', () => { + const update = { + status: 'idle', + }; + + expect(() => AwarenessUpdateSchema.parse(update)).not.toThrow(); + }); + + it('should accept multiple field updates', () => { + const update = { + status: 'active', + currentDocument: 'doc-789', + currentView: '/dashboard', + metadata: { activity: 'editing' }, + }; + + expect(() => AwarenessUpdateSchema.parse(update)).not.toThrow(); + }); +}); + +describe('AwarenessEventSchema', () => { + it('should accept user joined event', () => { + const event = { + eventId: '550e8400-e29b-41d4-a716-446655440000', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + eventType: 'user.joined', + userId: 'user-123', + timestamp: '2024-01-15T10:30:00Z', + payload: { userName: 'John Doe' }, + }; + + expect(() => AwarenessEventSchema.parse(event)).not.toThrow(); + }); + + it('should accept all event types', () => { + const eventTypes = [ + 'user.joined', + 'user.left', + 'user.updated', + 'session.created', + 'session.ended', + ]; + + eventTypes.forEach(eventType => { + const event = { + eventId: '550e8400-e29b-41d4-a716-446655440000', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + eventType, + timestamp: '2024-01-15T10:30:00Z', + payload: {}, + }; + + expect(() => AwarenessEventSchema.parse(event)).not.toThrow(); + }); + }); +}); + +describe('CollaborationMode', () => { + it('should accept valid collaboration modes', () => { + const modes = ['ot', 'crdt', 'lock', 'hybrid']; + + modes.forEach(mode => { + expect(() => CollaborationMode.parse(mode)).not.toThrow(); + }); + }); + + it('should reject invalid modes', () => { + expect(() => CollaborationMode.parse('p2p')).toThrow(); + expect(() => CollaborationMode.parse('centralized')).toThrow(); + }); +}); + +describe('CollaborationSessionConfigSchema', () => { + it('should accept minimal config', () => { + const config = { + mode: 'ot', + }; + + expect(() => CollaborationSessionConfigSchema.parse(config)).not.toThrow(); + }); + + it('should accept config with all options', () => { + const config = { + mode: 'crdt', + enableCursorSharing: true, + enablePresence: true, + enableAwareness: false, + maxUsers: 50, + idleTimeout: 600000, + conflictResolution: 'crdt', + persistence: true, + snapshot: { + enabled: true, + interval: 60000, + }, + }; + + const parsed = CollaborationSessionConfigSchema.parse(config); + expect(parsed.maxUsers).toBe(50); + expect(parsed.snapshot?.enabled).toBe(true); + }); + + it('should use default values', () => { + const config = { mode: 'ot' }; + + const parsed = CollaborationSessionConfigSchema.parse(config); + expect(parsed.enableCursorSharing).toBe(true); + expect(parsed.enablePresence).toBe(true); + expect(parsed.enableAwareness).toBe(true); + expect(parsed.idleTimeout).toBe(300000); + expect(parsed.conflictResolution).toBe('ot'); + expect(parsed.persistence).toBe(true); + }); +}); + +describe('CollaborationSessionSchema', () => { + it('should accept valid collaboration session', () => { + const session: CollaborationSession = { + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + config: { mode: 'ot' }, + users: [], + cursors: [], + version: 42, + createdAt: '2024-01-15T10:00:00Z', + lastActivity: '2024-01-15T10:30:00Z', + status: 'active', + }; + + expect(() => CollaborationSessionSchema.parse(session)).not.toThrow(); + }); + + it('should accept session with operations', () => { + const session = { + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + config: { mode: 'ot' }, + users: [], + cursors: [], + version: 5, + operations: [ + { + operationId: '550e8400-e29b-41d4-a716-446655440001', + documentId: 'doc-123', + userId: 'user-456', + sessionId: '550e8400-e29b-41d4-a716-446655440002', + components: [{ type: 'insert', text: 'Hello' }], + baseVersion: 4, + timestamp: '2024-01-15T10:30:00Z', + }, + ], + createdAt: '2024-01-15T10:00:00Z', + lastActivity: '2024-01-15T10:30:00Z', + status: 'active', + }; + + const parsed = CollaborationSessionSchema.parse(session); + expect(parsed.operations).toHaveLength(1); + }); + + it('should accept all status values', () => { + const statuses = ['active', 'idle', 'ended']; + + statuses.forEach(status => { + const session = { + sessionId: '550e8400-e29b-41d4-a716-446655440000', + documentId: 'doc-123', + config: { mode: 'ot' }, + users: [], + cursors: [], + version: 0, + createdAt: '2024-01-15T10:00:00Z', + lastActivity: '2024-01-15T10:30:00Z', + status, + }; + + const parsed = CollaborationSessionSchema.parse(session); + expect(parsed.status).toBe(status); + }); + }); +}); diff --git a/packages/spec/src/system/collaboration.zod.ts b/packages/spec/src/system/collaboration.zod.ts new file mode 100644 index 000000000..d11a27fda --- /dev/null +++ b/packages/spec/src/system/collaboration.zod.ts @@ -0,0 +1,482 @@ +import { z } from 'zod'; + +/** + * Real-Time Collaboration Protocol + * + * Defines schemas for real-time collaborative editing in ObjectStack. + * Supports Operational Transformation (OT), CRDT (Conflict-free Replicated Data Types), + * cursor sharing, and awareness state for collaborative applications. + * + * Industry alignment: Google Docs, Figma, VSCode Live Share, Yjs + */ + +// ========================================== +// Operational Transformation (OT) +// ========================================== + +/** + * OT Operation Type Enum + * Types of operations in Operational Transformation + */ +export const OTOperationType = z.enum([ + 'insert', // Insert characters at position + 'delete', // Delete characters at position + 'retain', // Keep characters (used for composing operations) +]); + +export type OTOperationType = z.infer; + +/** + * OT Operation Component + * Single component of an OT operation + */ +export const OTComponentSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('insert'), + text: z.string().describe('Text to insert'), + attributes: z.record(z.any()).optional().describe('Text formatting attributes (e.g., bold, italic)'), + }), + z.object({ + type: z.literal('delete'), + count: z.number().int().positive().describe('Number of characters to delete'), + }), + z.object({ + type: z.literal('retain'), + count: z.number().int().positive().describe('Number of characters to retain'), + attributes: z.record(z.any()).optional().describe('Attribute changes to apply'), + }), +]); + +export type OTComponent = z.infer; + +/** + * OT Operation Schema + * Represents a complete OT operation + * Based on the OT algorithm used by Google Docs and other collaborative editors + */ +export const OTOperationSchema = z.object({ + operationId: z.string().uuid().describe('Unique operation identifier'), + documentId: z.string().describe('Document identifier'), + userId: z.string().describe('User who created the operation'), + sessionId: z.string().uuid().describe('Session identifier'), + components: z.array(OTComponentSchema).describe('Operation components'), + baseVersion: z.number().int().nonnegative().describe('Document version this operation is based on'), + timestamp: z.string().datetime().describe('ISO 8601 datetime when operation was created'), + metadata: z.record(z.any()).optional().describe('Additional operation metadata'), +}); + +export type OTOperation = z.infer; + +/** + * OT Transform Result + * Result of transforming one operation against another + */ +export const OTTransformResultSchema = z.object({ + operation: OTOperationSchema.describe('Transformed operation'), + transformed: z.boolean().describe('Whether transformation was applied'), + conflicts: z.array(z.string()).optional().describe('Conflict descriptions if any'), +}); + +export type OTTransformResult = z.infer; + +// ========================================== +// CRDT (Conflict-free Replicated Data Types) +// ========================================== + +/** + * CRDT Type Enum + * Types of CRDTs supported + */ +export const CRDTType = z.enum([ + 'lww-register', // Last-Write-Wins Register + 'g-counter', // Grow-only Counter + 'pn-counter', // Positive-Negative Counter + 'g-set', // Grow-only Set + 'or-set', // Observed-Remove Set + 'lww-map', // Last-Write-Wins Map + 'text', // CRDT-based Text (e.g., Yjs, Automerge) + 'tree', // CRDT-based Tree structure + 'json', // CRDT-based JSON (e.g., Automerge) +]); + +export type CRDTType = z.infer; + +/** + * Vector Clock Schema + * Tracks causality in distributed systems + */ +export const VectorClockSchema = z.object({ + clock: z.record(z.number().int().nonnegative()).describe('Map of replica ID to logical timestamp'), +}); + +export type VectorClock = z.infer; + +/** + * LWW-Register Schema + * Last-Write-Wins Register CRDT + */ +export const LWWRegisterSchema = z.object({ + type: z.literal('lww-register'), + value: z.any().describe('Current register value'), + timestamp: z.string().datetime().describe('ISO 8601 datetime of last write'), + replicaId: z.string().describe('ID of replica that performed last write'), + vectorClock: VectorClockSchema.optional().describe('Optional vector clock for causality tracking'), +}); + +export type LWWRegister = z.infer; + +/** + * Counter Operation Schema + * Operations for Counter CRDTs + */ +export const CounterOperationSchema = z.object({ + replicaId: z.string().describe('Replica identifier'), + delta: z.number().int().describe('Change amount (positive for increment, negative for decrement)'), + timestamp: z.string().datetime().describe('ISO 8601 datetime of operation'), +}); + +export type CounterOperation = z.infer; + +/** + * G-Counter Schema + * Grow-only Counter CRDT + */ +export const GCounterSchema = z.object({ + type: z.literal('g-counter'), + counts: z.record(z.number().int().nonnegative()).describe('Map of replica ID to count'), +}); + +export type GCounter = z.infer; + +/** + * PN-Counter Schema + * Positive-Negative Counter CRDT (supports increment and decrement) + */ +export const PNCounterSchema = z.object({ + type: z.literal('pn-counter'), + positive: z.record(z.number().int().nonnegative()).describe('Positive increments per replica'), + negative: z.record(z.number().int().nonnegative()).describe('Negative increments per replica'), +}); + +export type PNCounter = z.infer; + +/** + * OR-Set Element Schema + * Element in an Observed-Remove Set + */ +export const ORSetElementSchema = z.object({ + value: z.any().describe('Element value'), + timestamp: z.string().datetime().describe('Addition timestamp'), + replicaId: z.string().describe('Replica that added the element'), + uid: z.string().uuid().describe('Unique identifier for this addition'), + removed: z.boolean().optional().default(false).describe('Whether element has been removed'), +}); + +export type ORSetElement = z.infer; + +/** + * OR-Set Schema + * Observed-Remove Set CRDT + */ +export const ORSetSchema = z.object({ + type: z.literal('or-set'), + elements: z.array(ORSetElementSchema).describe('Set elements with metadata'), +}); + +export type ORSet = z.infer; + +/** + * Text CRDT Operation Schema + * Operations for text-based CRDTs (e.g., Yjs, Automerge) + */ +export const TextCRDTOperationSchema = z.object({ + operationId: z.string().uuid().describe('Unique operation identifier'), + replicaId: z.string().describe('Replica identifier'), + position: z.number().int().nonnegative().describe('Position in document'), + insert: z.string().optional().describe('Text to insert'), + delete: z.number().int().positive().optional().describe('Number of characters to delete'), + timestamp: z.string().datetime().describe('ISO 8601 datetime of operation'), + lamportTimestamp: z.number().int().nonnegative().describe('Lamport timestamp for ordering'), +}); + +export type TextCRDTOperation = z.infer; + +/** + * Text CRDT State Schema + * State of a text-based CRDT document + */ +export const TextCRDTStateSchema = z.object({ + type: z.literal('text'), + documentId: z.string().describe('Document identifier'), + content: z.string().describe('Current text content'), + operations: z.array(TextCRDTOperationSchema).describe('History of operations'), + lamportClock: z.number().int().nonnegative().describe('Current Lamport clock value'), + vectorClock: VectorClockSchema.describe('Vector clock for causality'), +}); + +export type TextCRDTState = z.infer; + +/** + * CRDT State Union + * Discriminated union of all CRDT types + */ +export const CRDTStateSchema = z.discriminatedUnion('type', [ + LWWRegisterSchema, + GCounterSchema, + PNCounterSchema, + ORSetSchema, + TextCRDTStateSchema, +]); + +export type CRDTState = z.infer; + +/** + * CRDT Merge Schema + * Result of merging two CRDT states + */ +export const CRDTMergeResultSchema = z.object({ + state: CRDTStateSchema.describe('Merged CRDT state'), + conflicts: z.array(z.object({ + type: z.string().describe('Conflict type'), + description: z.string().describe('Conflict description'), + resolved: z.boolean().describe('Whether conflict was automatically resolved'), + })).optional().describe('Conflicts encountered during merge'), +}); + +export type CRDTMergeResult = z.infer; + +// ========================================== +// Cursor Sharing +// ========================================== + +/** + * Cursor Color Preset Enum + * Standard color presets for cursor visualization + */ +export const CursorColorPreset = z.enum([ + 'blue', + 'green', + 'red', + 'yellow', + 'purple', + 'orange', + 'pink', + 'teal', + 'indigo', + 'cyan', +]); + +export type CursorColorPreset = z.infer; + +/** + * Cursor Style Schema + * Visual styling for collaborative cursors + */ +export const CursorStyleSchema = z.object({ + color: z.union([CursorColorPreset, z.string()]).describe('Cursor color (preset or custom hex)'), + opacity: z.number().min(0).max(1).optional().default(1).describe('Cursor opacity (0-1)'), + label: z.string().optional().describe('Label to display with cursor (usually username)'), + showLabel: z.boolean().optional().default(true).describe('Whether to show label'), + pulseOnUpdate: z.boolean().optional().default(true).describe('Whether to pulse when cursor moves'), +}); + +export type CursorStyle = z.infer; + +/** + * Cursor Selection Schema + * Represents a text selection in collaborative editing + */ +export const CursorSelectionSchema = z.object({ + anchor: z.object({ + line: z.number().int().nonnegative().describe('Anchor line number'), + column: z.number().int().nonnegative().describe('Anchor column number'), + }).describe('Selection anchor (start point)'), + focus: z.object({ + line: z.number().int().nonnegative().describe('Focus line number'), + column: z.number().int().nonnegative().describe('Focus column number'), + }).describe('Selection focus (end point)'), + direction: z.enum(['forward', 'backward']).optional().describe('Selection direction'), +}); + +export type CursorSelection = z.infer; + +/** + * Collaborative Cursor Schema + * Complete cursor state for a collaborative user + */ +export const CollaborativeCursorSchema = z.object({ + userId: z.string().describe('User identifier'), + sessionId: z.string().uuid().describe('Session identifier'), + documentId: z.string().describe('Document identifier'), + userName: z.string().describe('Display name of user'), + position: z.object({ + line: z.number().int().nonnegative().describe('Cursor line number (0-indexed)'), + column: z.number().int().nonnegative().describe('Cursor column number (0-indexed)'), + }).describe('Current cursor position'), + selection: CursorSelectionSchema.optional().describe('Current text selection'), + style: CursorStyleSchema.describe('Visual style for this cursor'), + isTyping: z.boolean().optional().default(false).describe('Whether user is currently typing'), + lastUpdate: z.string().datetime().describe('ISO 8601 datetime of last cursor update'), + metadata: z.record(z.any()).optional().describe('Additional cursor metadata'), +}); + +export type CollaborativeCursor = z.infer; + +/** + * Cursor Update Schema + * Update to a collaborative cursor + */ +export const CursorUpdateSchema = z.object({ + position: z.object({ + line: z.number().int().nonnegative(), + column: z.number().int().nonnegative(), + }).optional().describe('Updated cursor position'), + selection: CursorSelectionSchema.optional().describe('Updated selection'), + isTyping: z.boolean().optional().describe('Updated typing state'), + metadata: z.record(z.any()).optional().describe('Updated metadata'), +}); + +export type CursorUpdate = z.infer; + +// ========================================== +// Awareness State +// ========================================== + +/** + * User Activity Status Enum + * User activity status for awareness + */ +export const UserActivityStatus = z.enum([ + 'active', // User is actively editing + 'idle', // User is idle but connected + 'viewing', // User is viewing but not editing + 'disconnected', // User is disconnected +]); + +export type UserActivityStatus = z.infer; + +/** + * Awareness User State Schema + * Tracks what a user is doing in the collaborative session + */ +export const AwarenessUserStateSchema = z.object({ + userId: z.string().describe('User identifier'), + sessionId: z.string().uuid().describe('Session identifier'), + userName: z.string().describe('Display name'), + userAvatar: z.string().optional().describe('User avatar URL'), + status: UserActivityStatus.describe('Current activity status'), + currentDocument: z.string().optional().describe('Document ID user is currently editing'), + currentView: z.string().optional().describe('Current view/page user is on'), + lastActivity: z.string().datetime().describe('ISO 8601 datetime of last activity'), + joinedAt: z.string().datetime().describe('ISO 8601 datetime when user joined session'), + permissions: z.array(z.string()).optional().describe('User permissions in this session'), + metadata: z.record(z.any()).optional().describe('Additional user state metadata'), +}); + +export type AwarenessUserState = z.infer; + +/** + * Awareness Session Schema + * Represents the complete awareness state for a collaboration session + */ +export const AwarenessSessionSchema = z.object({ + sessionId: z.string().uuid().describe('Session identifier'), + documentId: z.string().optional().describe('Document ID this session is for'), + users: z.array(AwarenessUserStateSchema).describe('Active users in session'), + startedAt: z.string().datetime().describe('ISO 8601 datetime when session started'), + lastUpdate: z.string().datetime().describe('ISO 8601 datetime of last update'), + metadata: z.record(z.any()).optional().describe('Session metadata'), +}); + +export type AwarenessSession = z.infer; + +/** + * Awareness Update Schema + * Update to awareness state + */ +export const AwarenessUpdateSchema = z.object({ + status: UserActivityStatus.optional().describe('Updated status'), + currentDocument: z.string().optional().describe('Updated current document'), + currentView: z.string().optional().describe('Updated current view'), + metadata: z.record(z.any()).optional().describe('Updated metadata'), +}); + +export type AwarenessUpdate = z.infer; + +/** + * Awareness Event Schema + * Events that occur in awareness tracking + */ +export const AwarenessEventSchema = z.object({ + eventId: z.string().uuid().describe('Event identifier'), + sessionId: z.string().uuid().describe('Session identifier'), + eventType: z.enum([ + 'user.joined', + 'user.left', + 'user.updated', + 'session.created', + 'session.ended', + ]).describe('Type of awareness event'), + userId: z.string().optional().describe('User involved in event'), + timestamp: z.string().datetime().describe('ISO 8601 datetime of event'), + payload: z.any().describe('Event payload'), +}); + +export type AwarenessEvent = z.infer; + +// ========================================== +// Collaboration Session Management +// ========================================== + +/** + * Collaboration Mode Enum + * Types of collaboration modes + */ +export const CollaborationMode = z.enum([ + 'ot', // Operational Transformation + 'crdt', // CRDT-based + 'lock', // Pessimistic locking (turn-based) + 'hybrid', // Hybrid approach +]); + +export type CollaborationMode = z.infer; + +/** + * Collaboration Session Config + * Configuration for a collaboration session + */ +export const CollaborationSessionConfigSchema = z.object({ + mode: CollaborationMode.describe('Collaboration mode to use'), + enableCursorSharing: z.boolean().optional().default(true).describe('Enable cursor sharing'), + enablePresence: z.boolean().optional().default(true).describe('Enable presence tracking'), + enableAwareness: z.boolean().optional().default(true).describe('Enable awareness state'), + maxUsers: z.number().int().positive().optional().describe('Maximum concurrent users'), + idleTimeout: z.number().int().positive().optional().default(300000).describe('Idle timeout in milliseconds'), + conflictResolution: z.enum(['ot', 'crdt', 'manual']).optional().default('ot').describe('Conflict resolution strategy'), + persistence: z.boolean().optional().default(true).describe('Enable operation persistence'), + snapshot: z.object({ + enabled: z.boolean().describe('Enable periodic snapshots'), + interval: z.number().int().positive().describe('Snapshot interval in milliseconds'), + }).optional().describe('Snapshot configuration'), +}); + +export type CollaborationSessionConfig = z.infer; + +/** + * Collaboration Session Schema + * Complete collaboration session state + */ +export const CollaborationSessionSchema = z.object({ + sessionId: z.string().uuid().describe('Session identifier'), + documentId: z.string().describe('Document identifier'), + config: CollaborationSessionConfigSchema.describe('Session configuration'), + users: z.array(AwarenessUserStateSchema).describe('Active users'), + cursors: z.array(CollaborativeCursorSchema).describe('Active cursors'), + version: z.number().int().nonnegative().describe('Current document version'), + operations: z.array(z.union([OTOperationSchema, TextCRDTOperationSchema])).optional().describe('Recent operations'), + createdAt: z.string().datetime().describe('ISO 8601 datetime when session was created'), + lastActivity: z.string().datetime().describe('ISO 8601 datetime of last activity'), + status: z.enum(['active', 'idle', 'ended']).describe('Session status'), +}); + +export type CollaborationSession = z.infer; diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index d3298d978..e1cc8f019 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -12,6 +12,7 @@ export * from './translation.zod'; export * from './events.zod'; export * from './job.zod'; export * from './feature.zod'; +export * from './collaboration.zod'; export * from './types'; // Re-export Core System Definitions