Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/spec/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
export * from './contract.zod';
export * from './endpoint.zod';
export * from './discovery.zod';
export * from './realtime-shared.zod';
export * from './realtime.zod';
export * from './websocket.zod';
export * from './router.zod';
Expand Down
139 changes: 139 additions & 0 deletions packages/spec/src/api/realtime-shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import {
PresenceStatus,
RealtimeRecordAction,
BasePresenceSchema,
type BasePresence,
} from './realtime-shared.zod';

describe('PresenceStatus (Shared)', () => {
it('should accept valid presence statuses', () => {
const statuses = ['online', 'away', 'busy', 'offline'];
statuses.forEach(status => {
expect(() => PresenceStatus.parse(status)).not.toThrow();
});
});

it('should reject invalid presence statuses', () => {
expect(() => PresenceStatus.parse('idle')).toThrow();
expect(() => PresenceStatus.parse('dnd')).toThrow();
expect(() => PresenceStatus.parse('')).toThrow();
});
});

describe('RealtimeRecordAction (Shared)', () => {
it('should accept valid record actions', () => {
const actions = ['created', 'updated', 'deleted'];
actions.forEach(action => {
expect(() => RealtimeRecordAction.parse(action)).not.toThrow();
});
});

it('should reject invalid record actions', () => {
expect(() => RealtimeRecordAction.parse('inserted')).toThrow();
expect(() => RealtimeRecordAction.parse('modified')).toThrow();
expect(() => RealtimeRecordAction.parse('')).toThrow();
});
});

describe('BasePresenceSchema (Shared)', () => {
it('should accept valid minimal presence', () => {
const presence: BasePresence = {
userId: 'user-123',
status: 'online',
lastSeen: '2024-01-15T10:30:00Z',
};

const result = BasePresenceSchema.parse(presence);
expect(result.userId).toBe('user-123');
expect(result.status).toBe('online');
expect(result.metadata).toBeUndefined();
});

it('should accept presence with metadata', () => {
const presence = {
userId: 'user-456',
status: 'away',
lastSeen: '2024-01-15T10:30:00Z',
metadata: {
currentPage: '/dashboard',
customStatus: 'In a meeting',
},
};

const result = BasePresenceSchema.parse(presence);
expect(result.metadata).toBeDefined();
expect(result.metadata?.currentPage).toBe('/dashboard');
});

it('should accept all presence statuses', () => {
const statuses: Array<BasePresence['status']> = ['online', 'away', 'busy', 'offline'];

statuses.forEach(status => {
const presence = {
userId: 'user-789',
status,
lastSeen: '2024-01-15T10:30:00Z',
};

const parsed = BasePresenceSchema.parse(presence);
expect(parsed.status).toBe(status);
});
});

it('should validate datetime format', () => {
expect(() => BasePresenceSchema.parse({
userId: 'user-123',
status: 'online',
lastSeen: 'not-a-datetime',
})).toThrow();
});

it('should reject presence without required fields', () => {
expect(() => BasePresenceSchema.parse({
status: 'online',
lastSeen: '2024-01-15T10:30:00Z',
})).toThrow();

expect(() => BasePresenceSchema.parse({
userId: 'user-123',
lastSeen: '2024-01-15T10:30:00Z',
})).toThrow();

expect(() => BasePresenceSchema.parse({
userId: 'user-123',
status: 'online',
})).toThrow();
});
});

describe('Cross-protocol consistency', () => {
it('should ensure PresenceStatus is used by both realtime and websocket protocols', async () => {
// Verify that both realtime.zod and websocket.zod use the shared PresenceStatus
const realtime = await import('./realtime.zod');
const websocket = await import('./websocket.zod');

// Both should accept the same presence status values
const testStatuses = ['online', 'away', 'busy', 'offline'];

testStatuses.forEach(status => {
expect(() => realtime.RealtimePresenceStatus.parse(status)).not.toThrow();
expect(() => websocket.WebSocketPresenceStatus.parse(status)).not.toThrow();
expect(() => PresenceStatus.parse(status)).not.toThrow();
});
});

it('should ensure RealtimePresenceSchema and BasePresenceSchema are compatible', () => {
const testPresence = {
userId: 'user-123',
status: 'online' as const,
lastSeen: '2024-01-15T10:30:00Z',
metadata: { page: '/dashboard' },
};

// Both schemas should parse the same data
const shared = BasePresenceSchema.parse(testPresence);
expect(shared.userId).toBe('user-123');
expect(shared.status).toBe('online');
});
});
99 changes: 99 additions & 0 deletions packages/spec/src/api/realtime-shared.zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';

/**
* Realtime Shared Protocol
*
* Shared schemas and types for real-time communication protocols.
* This module consolidates overlapping definitions between the transport-level
* realtime protocol (SSE/Polling/WebSocket) and the WebSocket collaboration protocol.
*
* **Architecture:**
* - `realtime-shared.zod.ts` — Shared base schemas (Presence, Event types)
* - `realtime.zod.ts` — Transport-layer protocol (Channel, Subscription, Transport selection)
* - `websocket.zod.ts` — Collaboration protocol (Cursor, OT editing, Advanced presence)
*
* @see realtime.zod.ts for transport-layer configuration
* @see websocket.zod.ts for collaborative editing protocol
*/

// ==========================================
// Shared Presence Status
// ==========================================

/**
* Presence Status Enum (Unified)
*
* Canonical user presence status shared across all realtime protocols.
* Used by both transport-level presence tracking (realtime.zod.ts)
* and WebSocket collaboration presence (websocket.zod.ts).
*
* @example
* ```typescript
* import { PresenceStatus } from './realtime-shared.zod';
* const status = PresenceStatus.parse('online'); // ✅
* ```
*/
export const PresenceStatus = z.enum([
'online', // User is actively connected
'away', // User is idle/inactive
'busy', // User is busy (do not disturb)
'offline', // User is disconnected
]);

export type PresenceStatus = z.infer<typeof PresenceStatus>;

// ==========================================
// Shared Realtime Actions
// ==========================================

/**
* Realtime Record Action Enum (Unified)
*
* Canonical action types for real-time record change events.
* Shared between transport-level events and WebSocket event messages.
*/
export const RealtimeRecordAction = z.enum([
'created',
'updated',
'deleted',
]);

export type RealtimeRecordAction = z.infer<typeof RealtimeRecordAction>;

// ==========================================
// Shared Base Presence Schema
// ==========================================

/**
* Base Presence Schema (Unified)
*
* Core presence fields shared across all realtime protocols.
* Transport-level (realtime.zod.ts) and collaboration-level (websocket.zod.ts)
* presence schemas extend this base with protocol-specific fields.
*
* @example
* ```typescript
* const presence = BasePresenceSchema.parse({
* userId: 'user-123',
* status: 'online',
* lastSeen: '2024-01-15T10:30:00Z',
* });
* ```
*/
export const BasePresenceSchema = z.object({
/** User identifier */
userId: z.string().describe('User identifier'),

/** Current presence status */
status: PresenceStatus.describe('Current presence status'),

/** Last activity timestamp */
lastSeen: z.string().datetime().describe('ISO 8601 datetime of last activity'),

/** Custom metadata */
metadata: z.record(z.string(), z.unknown()).optional().describe('Custom presence data (e.g., current page, custom status)'),
});

export type BasePresence = z.infer<typeof BasePresenceSchema>;
34 changes: 14 additions & 20 deletions packages/spec/src/api/realtime.zod.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { PresenceStatus, RealtimeRecordAction, BasePresenceSchema } from './realtime-shared.zod';

// Re-export shared types for backward compatibility
export { PresenceStatus, RealtimeRecordAction, BasePresenceSchema } from './realtime-shared.zod';
export type { BasePresence } from './realtime-shared.zod';

/**
* Transport Protocol Enum
Expand Down Expand Up @@ -52,39 +57,28 @@ export type Subscription = z.infer<typeof SubscriptionSchema>;

/**
* Presence Status Enum
* User online/offline status
* @deprecated Use `PresenceStatus` from `realtime-shared.zod.ts` instead.
* Kept for backward compatibility.
*/
export const RealtimePresenceStatus = z.enum([
'online',
'away',
'busy',
'offline',
]);
export const RealtimePresenceStatus = PresenceStatus;

export type RealtimePresenceStatus = z.infer<typeof RealtimePresenceStatus>;

/**
* Presence Schema
* Tracks user online status and metadata
* Tracks user online status and metadata.
* Extends the shared BasePresenceSchema for transport-level presence tracking.

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc says RealtimePresenceSchema “extends” BasePresenceSchema, but the code now aliases it directly (export const RealtimePresenceSchema = BasePresenceSchema). Either update the comment to reflect that it’s an alias, or actually extend the base with transport-specific fields if that’s the intent.

Suggested change
* Extends the shared BasePresenceSchema for transport-level presence tracking.
* Alias of BasePresenceSchema kept for backward compatibility with realtime-specific APIs.

Copilot uses AI. Check for mistakes.
*/
export const RealtimePresenceSchema = z.object({
userId: z.string().describe('User identifier'),
status: RealtimePresenceStatus.describe('Current presence status'),
lastSeen: z.string().datetime().describe('ISO 8601 datetime of last activity'),
metadata: z.record(z.string(), z.unknown()).optional().describe('Custom presence data (e.g., current page, custom status)'),
});
export const RealtimePresenceSchema = BasePresenceSchema;

export type RealtimePresence = z.infer<typeof RealtimePresenceSchema>;

/**
* Realtime Action Enum
* Actions that can occur on records
* @deprecated Use `RealtimeRecordAction` from `realtime-shared.zod.ts` instead.
* Kept for backward compatibility.
*/
export const RealtimeAction = z.enum([
'created',
'updated',
'deleted',
]);
export const RealtimeAction = RealtimeRecordAction;

export type RealtimeAction = z.infer<typeof RealtimeAction>;

Expand Down
9 changes: 6 additions & 3 deletions packages/spec/src/api/websocket.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import { z } from 'zod';
import { EventNameSchema } from '../shared/identifiers.zod';
import { RealtimePresenceStatus } from './realtime.zod';
import { PresenceStatus } from './realtime-shared.zod';

// Re-export shared PresenceStatus for backward compatibility

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the PresenceStatus re-export is “for backward compatibility”, but websocket.zod.ts previously only exposed WebSocketPresenceStatus (it didn’t export PresenceStatus). Consider clarifying this comment (e.g., “re-export canonical PresenceStatus for convenience/cross-protocol consistency”) to avoid confusion for consumers.

Suggested change
// Re-export shared PresenceStatus for backward compatibility
// Re-export canonical PresenceStatus for convenience and cross-protocol consistency

Copilot uses AI. Check for mistakes.
export { PresenceStatus } from './realtime-shared.zod';

/**
* WebSocket Event Protocol
Expand Down Expand Up @@ -136,9 +139,9 @@ export type UnsubscribeRequest = z.infer<typeof UnsubscribeRequestSchema>;

/**
* Presence Status Enum
* Re-exported from realtime.zod.ts for backward compatibility
* Re-exported from realtime-shared.zod.ts for backward compatibility
*/
export const WebSocketPresenceStatus = RealtimePresenceStatus;
export const WebSocketPresenceStatus = PresenceStatus;

export type WebSocketPresenceStatus = z.infer<typeof WebSocketPresenceStatus>;

Expand Down
7 changes: 4 additions & 3 deletions packages/spec/src/ui/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { z } from 'zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { I18nLabelSchema } from './i18n.zod';

/**
* Base Navigation Item Schema
Expand All @@ -24,7 +25,7 @@ const BaseNavItemSchema = z.object({
id: SnakeCaseIdentifierSchema.describe('Unique identifier for this navigation item (lowercase snake_case)'),

/** Display label */
label: z.string().describe('Display proper label'),
label: I18nLabelSchema.describe('Display proper label'),

/** Icon name (Lucide) */
icon: z.string().optional().describe('Icon name'),
Expand Down Expand Up @@ -156,13 +157,13 @@ export const AppSchema = z.object({
name: SnakeCaseIdentifierSchema.describe('App unique machine name (lowercase snake_case)'),

/** Display label */
label: z.string().describe('App display label'),
label: I18nLabelSchema.describe('App display label'),

/** App version */
version: z.string().optional().describe('App version'),

/** Description */
description: z.string().optional().describe('App description'),
description: I18nLabelSchema.optional().describe('App description'),

/** Icon name (Lucide) */
icon: z.string().optional().describe('App icon used in the App Launcher'),
Expand Down
Loading
Loading