Skip to content

Commit 63d9c43

Browse files
authored
Merge pull request #1020 from objectstack-ai/copilot/add-sys-presence-to-service-realtime
2 parents 001b924 + 6d5d6ad commit 63d9c43

9 files changed

Lines changed: 228 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **`@objectstack/service-realtime``sys_presence` System Object** — Registers the
12+
`sys_presence` system object in the `service-realtime` package as the canonical Presence
13+
domain object. Fields align with the `PresenceStateSchema` protocol definition
14+
(`user_id`, `session_id`, `status`, `last_seen`, `current_location`, `device`,
15+
`custom_status`, `metadata`). `RealtimeServicePlugin` now auto-registers the object
16+
via the `app.com.objectstack.service.realtime` service convention. Added
17+
`SystemObjectName.PRESENCE` constant (`'sys_presence'`) to `@objectstack/spec/system`.
1118
- **`@objectstack/service-ai` — Data Chatbot: Tool Call Loop & Agent Runtime** — Implements
1219
an Airtable Copilot-style data conversation Chatbot with full-stack support:
1320
- `AIService.chatWithTools()` — automatic multi-round LLM ↔ tool call loop with

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ Objects now declare `namespace: 'sys'` and a short `name` (e.g., `name: 'user'`)
337337
| `SystemObjectName.PERMISSION_SET` | `sys_permission_set` | plugin-security | Security: permission set grouping |
338338
| `SystemObjectName.AUDIT_LOG` | `sys_audit_log` | plugin-audit | Audit: immutable audit trail |
339339
| `SystemObjectName.METADATA` | `sys_metadata` | metadata | System metadata storage |
340+
| `SystemObjectName.PRESENCE` | `sys_presence` | service-realtime | Realtime: user presence state |
340341

341342
**Object Definition Convention:**
342343
- File naming: `sys-{name}.object.ts` (e.g., `sys-user.object.ts`, `sys-role.object.ts`)

packages/services/service-realtime/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { RealtimeServicePlugin } from './realtime-service-plugin.js';
44
export type { RealtimeServicePluginOptions } from './realtime-service-plugin.js';
55
export { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';
66
export type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';
7+
export { SysPresence } from './objects/index.js';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Realtime Service — System Object Definitions (sys namespace)
5+
*
6+
* Canonical ObjectSchema definitions for realtime-related system objects.
7+
*/
8+
9+
export { SysPresence } from './sys-presence.object.js';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SysPresence } from './sys-presence.object';
5+
6+
describe('SysPresence object definition', () => {
7+
it('should have correct namespace and name', () => {
8+
expect(SysPresence.namespace).toBe('sys');
9+
expect(SysPresence.name).toBe('presence');
10+
});
11+
12+
it('should auto-derive tableName as sys_presence', () => {
13+
expect(SysPresence.tableName).toBe('sys_presence');
14+
});
15+
16+
it('should be a system object', () => {
17+
expect(SysPresence.isSystem).toBe(true);
18+
});
19+
20+
it('should have label and pluralLabel', () => {
21+
expect(SysPresence.label).toBe('Presence');
22+
expect(SysPresence.pluralLabel).toBe('Presences');
23+
});
24+
25+
it('should define all presence protocol fields', () => {
26+
const fieldKeys = Object.keys(SysPresence.fields);
27+
expect(fieldKeys).toContain('id');
28+
expect(fieldKeys).toContain('created_at');
29+
expect(fieldKeys).toContain('updated_at');
30+
expect(fieldKeys).toContain('user_id');
31+
expect(fieldKeys).toContain('session_id');
32+
expect(fieldKeys).toContain('status');
33+
expect(fieldKeys).toContain('last_seen');
34+
expect(fieldKeys).toContain('current_location');
35+
expect(fieldKeys).toContain('device');
36+
expect(fieldKeys).toContain('custom_status');
37+
expect(fieldKeys).toContain('metadata');
38+
});
39+
40+
it('should have status field with correct options', () => {
41+
const statusField = SysPresence.fields.status;
42+
expect(statusField.type).toBe('select');
43+
expect(statusField.options).toEqual([
44+
{ value: 'online', label: 'Online' },
45+
{ value: 'away', label: 'Away' },
46+
{ value: 'busy', label: 'Busy' },
47+
{ value: 'offline', label: 'Offline' },
48+
]);
49+
});
50+
51+
it('should have device field with correct options', () => {
52+
const deviceField = SysPresence.fields.device;
53+
expect(deviceField.type).toBe('select');
54+
expect(deviceField.options).toEqual([
55+
{ value: 'desktop', label: 'Desktop' },
56+
{ value: 'mobile', label: 'Mobile' },
57+
{ value: 'tablet', label: 'Tablet' },
58+
{ value: 'other', label: 'Other' },
59+
]);
60+
});
61+
62+
it('should have indexes on user_id, session_id, and status', () => {
63+
expect(SysPresence.indexes).toEqual([
64+
{ fields: ['user_id'], unique: false, type: 'btree' },
65+
{ fields: ['session_id'], unique: true, type: 'btree' },
66+
{ fields: ['status'], unique: false, type: 'btree' },
67+
]);
68+
});
69+
70+
it('should have API enabled', () => {
71+
expect(SysPresence.enable?.apiEnabled).toBe(true);
72+
});
73+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_presence — System Presence Object
7+
*
8+
* Tracks real-time user presence and activity across the platform.
9+
* Fields align with the PresenceStateSchema protocol definition
10+
* from `@objectstack/spec/api` (websocket.zod.ts).
11+
*
12+
* Owned by `service-realtime` as the canonical Presence domain object.
13+
*
14+
* @namespace sys
15+
* @see PresenceStateSchema in packages/spec/src/api/websocket.zod.ts
16+
*/
17+
export const SysPresence = ObjectSchema.create({
18+
namespace: 'sys',
19+
name: 'presence',
20+
label: 'Presence',
21+
pluralLabel: 'Presences',
22+
icon: 'wifi',
23+
isSystem: true,
24+
description: 'Real-time user presence and activity tracking',
25+
titleFormat: '{user_id} ({status})',
26+
compactLayout: ['user_id', 'status', 'last_seen'],
27+
28+
fields: {
29+
id: Field.text({
30+
label: 'Presence ID',
31+
required: true,
32+
readonly: true,
33+
}),
34+
35+
created_at: Field.datetime({
36+
label: 'Created At',
37+
defaultValue: 'NOW()',
38+
readonly: true,
39+
}),
40+
41+
updated_at: Field.datetime({
42+
label: 'Updated At',
43+
defaultValue: 'NOW()',
44+
readonly: true,
45+
}),
46+
47+
user_id: Field.text({
48+
label: 'User ID',
49+
required: true,
50+
searchable: true,
51+
}),
52+
53+
session_id: Field.text({
54+
label: 'Session ID',
55+
required: true,
56+
}),
57+
58+
status: Field.select({
59+
label: 'Status',
60+
required: true,
61+
defaultValue: 'online',
62+
options: [
63+
{ value: 'online', label: 'Online' },
64+
{ value: 'away', label: 'Away' },
65+
{ value: 'busy', label: 'Busy' },
66+
{ value: 'offline', label: 'Offline' },
67+
],
68+
}),
69+
70+
last_seen: Field.datetime({
71+
label: 'Last Seen',
72+
required: true,
73+
defaultValue: 'NOW()',
74+
}),
75+
76+
current_location: Field.text({
77+
label: 'Current Location',
78+
required: false,
79+
maxLength: 500,
80+
}),
81+
82+
device: Field.select({
83+
label: 'Device',
84+
required: false,
85+
options: [
86+
{ value: 'desktop', label: 'Desktop' },
87+
{ value: 'mobile', label: 'Mobile' },
88+
{ value: 'tablet', label: 'Tablet' },
89+
{ value: 'other', label: 'Other' },
90+
],
91+
}),
92+
93+
custom_status: Field.text({
94+
label: 'Custom Status',
95+
required: false,
96+
maxLength: 255,
97+
}),
98+
99+
metadata: Field.json({
100+
label: 'Metadata',
101+
required: false,
102+
description: 'Arbitrary JSON metadata associated with the presence state (matches PresenceStateSchema.metadata).',
103+
}),
104+
},
105+
106+
indexes: [
107+
{ fields: ['user_id'], unique: false },
108+
{ fields: ['session_id'], unique: true },
109+
{ fields: ['status'], unique: false },
110+
],
111+
112+
enable: {
113+
trackHistory: false,
114+
searchable: false,
115+
apiEnabled: true,
116+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
117+
trash: false,
118+
mru: false,
119+
},
120+
});

packages/services/service-realtime/src/realtime-service-plugin.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';
55
import type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';
6+
import { SysPresence } from './objects/index.js';
67

78
/**
89
* Configuration options for the RealtimeServicePlugin.
@@ -49,6 +50,17 @@ export class RealtimeServicePlugin implements Plugin {
4950
async init(ctx: PluginContext): Promise<void> {
5051
const realtime = new InMemoryRealtimeAdapter(this.options.memory);
5152
ctx.registerService('realtime', realtime);
53+
54+
// Register realtime system objects so ObjectQLPlugin auto-discovers them
55+
ctx.registerService('app.com.objectstack.service.realtime', {
56+
id: 'com.objectstack.service.realtime',
57+
name: 'Realtime Service',
58+
version: '1.0.0',
59+
type: 'plugin',
60+
namespace: 'sys',
61+
objects: [SysPresence],
62+
});
63+
5264
ctx.logger.info('RealtimeServicePlugin: registered in-memory realtime adapter');
5365
}
5466
}

packages/spec/src/system/constants/system-names.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('SystemObjectName', () => {
2626
expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set');
2727
expect(SystemObjectName.AUDIT_LOG).toBe('sys_audit_log');
2828
expect(SystemObjectName.METADATA).toBe('sys_metadata');
29+
expect(SystemObjectName.PRESENCE).toBe('sys_presence');
2930
});
3031

3132
it('should be readonly (const assertion)', () => {
@@ -37,6 +38,7 @@ describe('SystemObjectName', () => {
3738
expect(names).toContain('sys_team_member');
3839
expect(names).toContain('sys_role');
3940
expect(names).toContain('sys_audit_log');
41+
expect(names).toContain('sys_presence');
4042
});
4143

4244
it('should have all expected keys', () => {
@@ -56,6 +58,7 @@ describe('SystemObjectName', () => {
5658
expect(keys).toContain('PERMISSION_SET');
5759
expect(keys).toContain('AUDIT_LOG');
5860
expect(keys).toContain('METADATA');
61+
expect(keys).toContain('PRESENCE');
5962
});
6063
});
6164

packages/spec/src/system/constants/system-names.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const SystemObjectName = {
5050
AUDIT_LOG: 'sys_audit_log',
5151
/** System metadata storage */
5252
METADATA: 'sys_metadata',
53+
/** Realtime: user presence state */
54+
PRESENCE: 'sys_presence',
5355
} as const;
5456

5557
/** Union type of all system object names */

0 commit comments

Comments
 (0)