Skip to content

Commit 3b48405

Browse files
authored
Merge pull request #604 from objectstack-ai/copilot/optimize-protocol-report
2 parents b774983 + 5f37ee8 commit 3b48405

11 files changed

Lines changed: 525 additions & 39 deletions

File tree

packages/spec/src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
export * from './contract.zod';
1717
export * from './endpoint.zod';
1818
export * from './discovery.zod';
19+
export * from './realtime-shared.zod';
1920
export * from './realtime.zod';
2021
export * from './websocket.zod';
2122
export * from './router.zod';
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
PresenceStatus,
4+
RealtimeRecordAction,
5+
BasePresenceSchema,
6+
type BasePresence,
7+
} from './realtime-shared.zod';
8+
9+
describe('PresenceStatus (Shared)', () => {
10+
it('should accept valid presence statuses', () => {
11+
const statuses = ['online', 'away', 'busy', 'offline'];
12+
statuses.forEach(status => {
13+
expect(() => PresenceStatus.parse(status)).not.toThrow();
14+
});
15+
});
16+
17+
it('should reject invalid presence statuses', () => {
18+
expect(() => PresenceStatus.parse('idle')).toThrow();
19+
expect(() => PresenceStatus.parse('dnd')).toThrow();
20+
expect(() => PresenceStatus.parse('')).toThrow();
21+
});
22+
});
23+
24+
describe('RealtimeRecordAction (Shared)', () => {
25+
it('should accept valid record actions', () => {
26+
const actions = ['created', 'updated', 'deleted'];
27+
actions.forEach(action => {
28+
expect(() => RealtimeRecordAction.parse(action)).not.toThrow();
29+
});
30+
});
31+
32+
it('should reject invalid record actions', () => {
33+
expect(() => RealtimeRecordAction.parse('inserted')).toThrow();
34+
expect(() => RealtimeRecordAction.parse('modified')).toThrow();
35+
expect(() => RealtimeRecordAction.parse('')).toThrow();
36+
});
37+
});
38+
39+
describe('BasePresenceSchema (Shared)', () => {
40+
it('should accept valid minimal presence', () => {
41+
const presence: BasePresence = {
42+
userId: 'user-123',
43+
status: 'online',
44+
lastSeen: '2024-01-15T10:30:00Z',
45+
};
46+
47+
const result = BasePresenceSchema.parse(presence);
48+
expect(result.userId).toBe('user-123');
49+
expect(result.status).toBe('online');
50+
expect(result.metadata).toBeUndefined();
51+
});
52+
53+
it('should accept presence with metadata', () => {
54+
const presence = {
55+
userId: 'user-456',
56+
status: 'away',
57+
lastSeen: '2024-01-15T10:30:00Z',
58+
metadata: {
59+
currentPage: '/dashboard',
60+
customStatus: 'In a meeting',
61+
},
62+
};
63+
64+
const result = BasePresenceSchema.parse(presence);
65+
expect(result.metadata).toBeDefined();
66+
expect(result.metadata?.currentPage).toBe('/dashboard');
67+
});
68+
69+
it('should accept all presence statuses', () => {
70+
const statuses: Array<BasePresence['status']> = ['online', 'away', 'busy', 'offline'];
71+
72+
statuses.forEach(status => {
73+
const presence = {
74+
userId: 'user-789',
75+
status,
76+
lastSeen: '2024-01-15T10:30:00Z',
77+
};
78+
79+
const parsed = BasePresenceSchema.parse(presence);
80+
expect(parsed.status).toBe(status);
81+
});
82+
});
83+
84+
it('should validate datetime format', () => {
85+
expect(() => BasePresenceSchema.parse({
86+
userId: 'user-123',
87+
status: 'online',
88+
lastSeen: 'not-a-datetime',
89+
})).toThrow();
90+
});
91+
92+
it('should reject presence without required fields', () => {
93+
expect(() => BasePresenceSchema.parse({
94+
status: 'online',
95+
lastSeen: '2024-01-15T10:30:00Z',
96+
})).toThrow();
97+
98+
expect(() => BasePresenceSchema.parse({
99+
userId: 'user-123',
100+
lastSeen: '2024-01-15T10:30:00Z',
101+
})).toThrow();
102+
103+
expect(() => BasePresenceSchema.parse({
104+
userId: 'user-123',
105+
status: 'online',
106+
})).toThrow();
107+
});
108+
});
109+
110+
describe('Cross-protocol consistency', () => {
111+
it('should ensure PresenceStatus is used by both realtime and websocket protocols', async () => {
112+
// Verify that both realtime.zod and websocket.zod use the shared PresenceStatus
113+
const realtime = await import('./realtime.zod');
114+
const websocket = await import('./websocket.zod');
115+
116+
// Both should accept the same presence status values
117+
const testStatuses = ['online', 'away', 'busy', 'offline'];
118+
119+
testStatuses.forEach(status => {
120+
expect(() => realtime.RealtimePresenceStatus.parse(status)).not.toThrow();
121+
expect(() => websocket.WebSocketPresenceStatus.parse(status)).not.toThrow();
122+
expect(() => PresenceStatus.parse(status)).not.toThrow();
123+
});
124+
});
125+
126+
it('should ensure RealtimePresenceSchema and BasePresenceSchema are compatible', () => {
127+
const testPresence = {
128+
userId: 'user-123',
129+
status: 'online' as const,
130+
lastSeen: '2024-01-15T10:30:00Z',
131+
metadata: { page: '/dashboard' },
132+
};
133+
134+
// Both schemas should parse the same data
135+
const shared = BasePresenceSchema.parse(testPresence);
136+
expect(shared.userId).toBe('user-123');
137+
expect(shared.status).toBe('online');
138+
});
139+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { z } from 'zod';
4+
5+
/**
6+
* Realtime Shared Protocol
7+
*
8+
* Shared schemas and types for real-time communication protocols.
9+
* This module consolidates overlapping definitions between the transport-level
10+
* realtime protocol (SSE/Polling/WebSocket) and the WebSocket collaboration protocol.
11+
*
12+
* **Architecture:**
13+
* - `realtime-shared.zod.ts` — Shared base schemas (Presence, Event types)
14+
* - `realtime.zod.ts` — Transport-layer protocol (Channel, Subscription, Transport selection)
15+
* - `websocket.zod.ts` — Collaboration protocol (Cursor, OT editing, Advanced presence)
16+
*
17+
* @see realtime.zod.ts for transport-layer configuration
18+
* @see websocket.zod.ts for collaborative editing protocol
19+
*/
20+
21+
// ==========================================
22+
// Shared Presence Status
23+
// ==========================================
24+
25+
/**
26+
* Presence Status Enum (Unified)
27+
*
28+
* Canonical user presence status shared across all realtime protocols.
29+
* Used by both transport-level presence tracking (realtime.zod.ts)
30+
* and WebSocket collaboration presence (websocket.zod.ts).
31+
*
32+
* @example
33+
* ```typescript
34+
* import { PresenceStatus } from './realtime-shared.zod';
35+
* const status = PresenceStatus.parse('online'); // ✅
36+
* ```
37+
*/
38+
export const PresenceStatus = z.enum([
39+
'online', // User is actively connected
40+
'away', // User is idle/inactive
41+
'busy', // User is busy (do not disturb)
42+
'offline', // User is disconnected
43+
]);
44+
45+
export type PresenceStatus = z.infer<typeof PresenceStatus>;
46+
47+
// ==========================================
48+
// Shared Realtime Actions
49+
// ==========================================
50+
51+
/**
52+
* Realtime Record Action Enum (Unified)
53+
*
54+
* Canonical action types for real-time record change events.
55+
* Shared between transport-level events and WebSocket event messages.
56+
*/
57+
export const RealtimeRecordAction = z.enum([
58+
'created',
59+
'updated',
60+
'deleted',
61+
]);
62+
63+
export type RealtimeRecordAction = z.infer<typeof RealtimeRecordAction>;
64+
65+
// ==========================================
66+
// Shared Base Presence Schema
67+
// ==========================================
68+
69+
/**
70+
* Base Presence Schema (Unified)
71+
*
72+
* Core presence fields shared across all realtime protocols.
73+
* Transport-level (realtime.zod.ts) and collaboration-level (websocket.zod.ts)
74+
* presence schemas extend this base with protocol-specific fields.
75+
*
76+
* @example
77+
* ```typescript
78+
* const presence = BasePresenceSchema.parse({
79+
* userId: 'user-123',
80+
* status: 'online',
81+
* lastSeen: '2024-01-15T10:30:00Z',
82+
* });
83+
* ```
84+
*/
85+
export const BasePresenceSchema = z.object({
86+
/** User identifier */
87+
userId: z.string().describe('User identifier'),
88+
89+
/** Current presence status */
90+
status: PresenceStatus.describe('Current presence status'),
91+
92+
/** Last activity timestamp */
93+
lastSeen: z.string().datetime().describe('ISO 8601 datetime of last activity'),
94+
95+
/** Custom metadata */
96+
metadata: z.record(z.string(), z.unknown()).optional().describe('Custom presence data (e.g., current page, custom status)'),
97+
});
98+
99+
export type BasePresence = z.infer<typeof BasePresenceSchema>;

packages/spec/src/api/realtime.zod.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { z } from 'zod';
4+
import { PresenceStatus, RealtimeRecordAction, BasePresenceSchema } from './realtime-shared.zod';
5+
6+
// Re-export shared types for backward compatibility
7+
export { PresenceStatus, RealtimeRecordAction, BasePresenceSchema } from './realtime-shared.zod';
8+
export type { BasePresence } from './realtime-shared.zod';
49

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

5358
/**
5459
* Presence Status Enum
55-
* User online/offline status
60+
* @deprecated Use `PresenceStatus` from `realtime-shared.zod.ts` instead.
61+
* Kept for backward compatibility.
5662
*/
57-
export const RealtimePresenceStatus = z.enum([
58-
'online',
59-
'away',
60-
'busy',
61-
'offline',
62-
]);
63+
export const RealtimePresenceStatus = PresenceStatus;
6364

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

6667
/**
6768
* Presence Schema
68-
* Tracks user online status and metadata
69+
* Tracks user online status and metadata.
70+
* Extends the shared BasePresenceSchema for transport-level presence tracking.
6971
*/
70-
export const RealtimePresenceSchema = z.object({
71-
userId: z.string().describe('User identifier'),
72-
status: RealtimePresenceStatus.describe('Current presence status'),
73-
lastSeen: z.string().datetime().describe('ISO 8601 datetime of last activity'),
74-
metadata: z.record(z.string(), z.unknown()).optional().describe('Custom presence data (e.g., current page, custom status)'),
75-
});
72+
export const RealtimePresenceSchema = BasePresenceSchema;
7673

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

7976
/**
8077
* Realtime Action Enum
81-
* Actions that can occur on records
78+
* @deprecated Use `RealtimeRecordAction` from `realtime-shared.zod.ts` instead.
79+
* Kept for backward compatibility.
8280
*/
83-
export const RealtimeAction = z.enum([
84-
'created',
85-
'updated',
86-
'deleted',
87-
]);
81+
export const RealtimeAction = RealtimeRecordAction;
8882

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

packages/spec/src/api/websocket.zod.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import { z } from 'zod';
44
import { EventNameSchema } from '../shared/identifiers.zod';
5-
import { RealtimePresenceStatus } from './realtime.zod';
5+
import { PresenceStatus } from './realtime-shared.zod';
6+
7+
// Re-export shared PresenceStatus for backward compatibility
8+
export { PresenceStatus } from './realtime-shared.zod';
69

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

137140
/**
138141
* Presence Status Enum
139-
* Re-exported from realtime.zod.ts for backward compatibility
142+
* Re-exported from realtime-shared.zod.ts for backward compatibility
140143
*/
141-
export const WebSocketPresenceStatus = RealtimePresenceStatus;
144+
export const WebSocketPresenceStatus = PresenceStatus;
142145

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

packages/spec/src/ui/app.zod.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
5+
import { I18nLabelSchema } from './i18n.zod';
56

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

2627
/** Display label */
27-
label: z.string().describe('Display proper label'),
28+
label: I18nLabelSchema.describe('Display proper label'),
2829

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

158159
/** Display label */
159-
label: z.string().describe('App display label'),
160+
label: I18nLabelSchema.describe('App display label'),
160161

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

164165
/** Description */
165-
description: z.string().optional().describe('App description'),
166+
description: I18nLabelSchema.optional().describe('App description'),
166167

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

0 commit comments

Comments
 (0)