diff --git a/CHANGELOG.md b/CHANGELOG.md index 688ddd1d7..0f3dc2c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`@objectstack/service-realtime` — `sys_presence` System Object** — Registers the + `sys_presence` system object in the `service-realtime` package as the canonical Presence + domain object. Fields align with the `PresenceStateSchema` protocol definition + (`user_id`, `session_id`, `status`, `last_seen`, `current_location`, `device`, + `custom_status`, `metadata`). `RealtimeServicePlugin` now auto-registers the object + via the `app.com.objectstack.service.realtime` service convention. Added + `SystemObjectName.PRESENCE` constant (`'sys_presence'`) to `@objectstack/spec/system`. - **`@objectstack/service-ai` — Data Chatbot: Tool Call Loop & Agent Runtime** — Implements an Airtable Copilot-style data conversation Chatbot with full-stack support: - `AIService.chatWithTools()` — automatic multi-round LLM ↔ tool call loop with diff --git a/ROADMAP.md b/ROADMAP.md index a5e011348..2ab2e032c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -337,6 +337,7 @@ Objects now declare `namespace: 'sys'` and a short `name` (e.g., `name: 'user'`) | `SystemObjectName.PERMISSION_SET` | `sys_permission_set` | plugin-security | Security: permission set grouping | | `SystemObjectName.AUDIT_LOG` | `sys_audit_log` | plugin-audit | Audit: immutable audit trail | | `SystemObjectName.METADATA` | `sys_metadata` | metadata | System metadata storage | +| `SystemObjectName.PRESENCE` | `sys_presence` | service-realtime | Realtime: user presence state | **Object Definition Convention:** - File naming: `sys-{name}.object.ts` (e.g., `sys-user.object.ts`, `sys-role.object.ts`) diff --git a/packages/services/service-realtime/src/index.ts b/packages/services/service-realtime/src/index.ts index 2b6486d61..429e529c6 100644 --- a/packages/services/service-realtime/src/index.ts +++ b/packages/services/service-realtime/src/index.ts @@ -4,3 +4,4 @@ export { RealtimeServicePlugin } from './realtime-service-plugin.js'; export type { RealtimeServicePluginOptions } from './realtime-service-plugin.js'; export { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js'; export type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js'; +export { SysPresence } from './objects/index.js'; diff --git a/packages/services/service-realtime/src/objects/index.ts b/packages/services/service-realtime/src/objects/index.ts new file mode 100644 index 000000000..da6b742b3 --- /dev/null +++ b/packages/services/service-realtime/src/objects/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Realtime Service — System Object Definitions (sys namespace) + * + * Canonical ObjectSchema definitions for realtime-related system objects. + */ + +export { SysPresence } from './sys-presence.object.js'; diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts new file mode 100644 index 000000000..7354f078f --- /dev/null +++ b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysPresence } from './sys-presence.object'; + +describe('SysPresence object definition', () => { + it('should have correct namespace and name', () => { + expect(SysPresence.namespace).toBe('sys'); + expect(SysPresence.name).toBe('presence'); + }); + + it('should auto-derive tableName as sys_presence', () => { + expect(SysPresence.tableName).toBe('sys_presence'); + }); + + it('should be a system object', () => { + expect(SysPresence.isSystem).toBe(true); + }); + + it('should have label and pluralLabel', () => { + expect(SysPresence.label).toBe('Presence'); + expect(SysPresence.pluralLabel).toBe('Presences'); + }); + + it('should define all presence protocol fields', () => { + const fieldKeys = Object.keys(SysPresence.fields); + expect(fieldKeys).toContain('id'); + expect(fieldKeys).toContain('created_at'); + expect(fieldKeys).toContain('updated_at'); + expect(fieldKeys).toContain('user_id'); + expect(fieldKeys).toContain('session_id'); + expect(fieldKeys).toContain('status'); + expect(fieldKeys).toContain('last_seen'); + expect(fieldKeys).toContain('current_location'); + expect(fieldKeys).toContain('device'); + expect(fieldKeys).toContain('custom_status'); + expect(fieldKeys).toContain('metadata'); + }); + + it('should have status field with correct options', () => { + const statusField = SysPresence.fields.status; + expect(statusField.type).toBe('select'); + expect(statusField.options).toEqual([ + { value: 'online', label: 'Online' }, + { value: 'away', label: 'Away' }, + { value: 'busy', label: 'Busy' }, + { value: 'offline', label: 'Offline' }, + ]); + }); + + it('should have device field with correct options', () => { + const deviceField = SysPresence.fields.device; + expect(deviceField.type).toBe('select'); + expect(deviceField.options).toEqual([ + { value: 'desktop', label: 'Desktop' }, + { value: 'mobile', label: 'Mobile' }, + { value: 'tablet', label: 'Tablet' }, + { value: 'other', label: 'Other' }, + ]); + }); + + it('should have indexes on user_id, session_id, and status', () => { + expect(SysPresence.indexes).toEqual([ + { fields: ['user_id'], unique: false, type: 'btree' }, + { fields: ['session_id'], unique: true, type: 'btree' }, + { fields: ['status'], unique: false, type: 'btree' }, + ]); + }); + + it('should have API enabled', () => { + expect(SysPresence.enable?.apiEnabled).toBe(true); + }); +}); diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts new file mode 100644 index 000000000..e224f0216 --- /dev/null +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_presence — System Presence Object + * + * Tracks real-time user presence and activity across the platform. + * Fields align with the PresenceStateSchema protocol definition + * from `@objectstack/spec/api` (websocket.zod.ts). + * + * Owned by `service-realtime` as the canonical Presence domain object. + * + * @namespace sys + * @see PresenceStateSchema in packages/spec/src/api/websocket.zod.ts + */ +export const SysPresence = ObjectSchema.create({ + namespace: 'sys', + name: 'presence', + label: 'Presence', + pluralLabel: 'Presences', + icon: 'wifi', + isSystem: true, + description: 'Real-time user presence and activity tracking', + titleFormat: '{user_id} ({status})', + compactLayout: ['user_id', 'status', 'last_seen'], + + fields: { + id: Field.text({ + label: 'Presence ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + searchable: true, + }), + + session_id: Field.text({ + label: 'Session ID', + required: true, + }), + + status: Field.select({ + label: 'Status', + required: true, + defaultValue: 'online', + options: [ + { value: 'online', label: 'Online' }, + { value: 'away', label: 'Away' }, + { value: 'busy', label: 'Busy' }, + { value: 'offline', label: 'Offline' }, + ], + }), + + last_seen: Field.datetime({ + label: 'Last Seen', + required: true, + defaultValue: 'NOW()', + }), + + current_location: Field.text({ + label: 'Current Location', + required: false, + maxLength: 500, + }), + + device: Field.select({ + label: 'Device', + required: false, + options: [ + { value: 'desktop', label: 'Desktop' }, + { value: 'mobile', label: 'Mobile' }, + { value: 'tablet', label: 'Tablet' }, + { value: 'other', label: 'Other' }, + ], + }), + + custom_status: Field.text({ + label: 'Custom Status', + required: false, + maxLength: 255, + }), + + metadata: Field.json({ + label: 'Metadata', + required: false, + description: 'Arbitrary JSON metadata associated with the presence state (matches PresenceStateSchema.metadata).', + }), + }, + + indexes: [ + { fields: ['user_id'], unique: false }, + { fields: ['session_id'], unique: true }, + { fields: ['status'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/services/service-realtime/src/realtime-service-plugin.ts b/packages/services/service-realtime/src/realtime-service-plugin.ts index cd5281326..3fc23b71e 100644 --- a/packages/services/service-realtime/src/realtime-service-plugin.ts +++ b/packages/services/service-realtime/src/realtime-service-plugin.ts @@ -3,6 +3,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js'; import type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js'; +import { SysPresence } from './objects/index.js'; /** * Configuration options for the RealtimeServicePlugin. @@ -49,6 +50,17 @@ export class RealtimeServicePlugin implements Plugin { async init(ctx: PluginContext): Promise { const realtime = new InMemoryRealtimeAdapter(this.options.memory); ctx.registerService('realtime', realtime); + + // Register realtime system objects so ObjectQLPlugin auto-discovers them + ctx.registerService('app.com.objectstack.service.realtime', { + id: 'com.objectstack.service.realtime', + name: 'Realtime Service', + version: '1.0.0', + type: 'plugin', + namespace: 'sys', + objects: [SysPresence], + }); + ctx.logger.info('RealtimeServicePlugin: registered in-memory realtime adapter'); } } diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index 29009f571..aa6804586 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -26,6 +26,7 @@ describe('SystemObjectName', () => { expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set'); expect(SystemObjectName.AUDIT_LOG).toBe('sys_audit_log'); expect(SystemObjectName.METADATA).toBe('sys_metadata'); + expect(SystemObjectName.PRESENCE).toBe('sys_presence'); }); it('should be readonly (const assertion)', () => { @@ -37,6 +38,7 @@ describe('SystemObjectName', () => { expect(names).toContain('sys_team_member'); expect(names).toContain('sys_role'); expect(names).toContain('sys_audit_log'); + expect(names).toContain('sys_presence'); }); it('should have all expected keys', () => { @@ -56,6 +58,7 @@ describe('SystemObjectName', () => { expect(keys).toContain('PERMISSION_SET'); expect(keys).toContain('AUDIT_LOG'); expect(keys).toContain('METADATA'); + expect(keys).toContain('PRESENCE'); }); }); diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 9b071c73b..5989b22b1 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -50,6 +50,8 @@ export const SystemObjectName = { AUDIT_LOG: 'sys_audit_log', /** System metadata storage */ METADATA: 'sys_metadata', + /** Realtime: user presence state */ + PRESENCE: 'sys_presence', } as const; /** Union type of all system object names */