@objectstack/service-realtime 4.0.4 → 4.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +6 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -2132
- package/dist/index.d.ts +1 -2132
- package/dist/index.js +4 -96
- package/dist/index.js.map +1 -1
- package/package.json +32 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -168
- package/src/in-memory-realtime-adapter.test.ts +0 -242
- package/src/in-memory-realtime-adapter.ts +0 -154
- package/src/index.ts +0 -7
- package/src/objects/index.ts +0 -9
- package/src/objects/sys-presence.object.test.ts +0 -73
- package/src/objects/sys-presence.object.ts +0 -120
- package/src/realtime-service-plugin.ts +0 -67
- package/tsconfig.json +0 -17
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
IRealtimeService,
|
|
5
|
-
RealtimeEventPayload,
|
|
6
|
-
RealtimeEventHandler,
|
|
7
|
-
RealtimeSubscriptionOptions,
|
|
8
|
-
} from '@objectstack/spec/contracts';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Internal subscription entry.
|
|
12
|
-
*/
|
|
13
|
-
interface Subscription {
|
|
14
|
-
id: string;
|
|
15
|
-
channel: string;
|
|
16
|
-
handler: RealtimeEventHandler;
|
|
17
|
-
options?: RealtimeSubscriptionOptions;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Configuration options for InMemoryRealtimeAdapter.
|
|
22
|
-
*/
|
|
23
|
-
export interface InMemoryRealtimeAdapterOptions {
|
|
24
|
-
/** Maximum number of subscriptions allowed (0 = unlimited) */
|
|
25
|
-
maxSubscriptions?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* In-memory pub/sub adapter implementing IRealtimeService.
|
|
30
|
-
*
|
|
31
|
-
* Uses a Map-backed subscription store with channel-based routing.
|
|
32
|
-
* Supports event type and object filtering via subscription options.
|
|
33
|
-
*
|
|
34
|
-
* Suitable for single-process environments, development, and testing.
|
|
35
|
-
* For production multi-instance deployments, use a Redis-backed adapter.
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* ```ts
|
|
39
|
-
* const realtime = new InMemoryRealtimeAdapter();
|
|
40
|
-
*
|
|
41
|
-
* const subId = await realtime.subscribe('records', (event) => {
|
|
42
|
-
* console.log('Received:', event.type, event.payload);
|
|
43
|
-
* }, { object: 'account', eventTypes: ['record.created'] });
|
|
44
|
-
*
|
|
45
|
-
* await realtime.publish({
|
|
46
|
-
* type: 'record.created',
|
|
47
|
-
* object: 'account',
|
|
48
|
-
* payload: { id: 'acc-1', name: 'Acme' },
|
|
49
|
-
* timestamp: new Date().toISOString(),
|
|
50
|
-
* });
|
|
51
|
-
*
|
|
52
|
-
* await realtime.unsubscribe(subId);
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
export class InMemoryRealtimeAdapter implements IRealtimeService {
|
|
56
|
-
private readonly subscriptions = new Map<string, Subscription>();
|
|
57
|
-
private readonly channelIndex = new Map<string, Set<string>>();
|
|
58
|
-
private counter = 0;
|
|
59
|
-
private readonly maxSubscriptions: number;
|
|
60
|
-
|
|
61
|
-
constructor(options: InMemoryRealtimeAdapterOptions = {}) {
|
|
62
|
-
this.maxSubscriptions = options.maxSubscriptions ?? 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async publish(event: RealtimeEventPayload): Promise<void> {
|
|
66
|
-
// Deliver to all channel subscriptions that match filters
|
|
67
|
-
for (const sub of this.subscriptions.values()) {
|
|
68
|
-
if (this.matchesSubscription(event, sub)) {
|
|
69
|
-
try {
|
|
70
|
-
await sub.handler(event);
|
|
71
|
-
} catch {
|
|
72
|
-
// Swallow handler errors to avoid breaking the publish loop
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async subscribe(
|
|
79
|
-
channel: string,
|
|
80
|
-
handler: RealtimeEventHandler,
|
|
81
|
-
options?: RealtimeSubscriptionOptions,
|
|
82
|
-
): Promise<string> {
|
|
83
|
-
if (this.maxSubscriptions > 0 && this.subscriptions.size >= this.maxSubscriptions) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`Maximum subscription limit reached (${this.maxSubscriptions}). ` +
|
|
86
|
-
'Unsubscribe from existing channels before adding new subscriptions.',
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const id = `sub-${++this.counter}`;
|
|
91
|
-
const sub: Subscription = { id, channel, handler, options };
|
|
92
|
-
this.subscriptions.set(id, sub);
|
|
93
|
-
|
|
94
|
-
// Maintain channel index for efficient lookups
|
|
95
|
-
if (!this.channelIndex.has(channel)) {
|
|
96
|
-
this.channelIndex.set(channel, new Set());
|
|
97
|
-
}
|
|
98
|
-
this.channelIndex.get(channel)!.add(id);
|
|
99
|
-
|
|
100
|
-
return id;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async unsubscribe(subscriptionId: string): Promise<void> {
|
|
104
|
-
const sub = this.subscriptions.get(subscriptionId);
|
|
105
|
-
if (!sub) return;
|
|
106
|
-
|
|
107
|
-
this.subscriptions.delete(subscriptionId);
|
|
108
|
-
|
|
109
|
-
// Clean up channel index
|
|
110
|
-
const channelSubs = this.channelIndex.get(sub.channel);
|
|
111
|
-
if (channelSubs) {
|
|
112
|
-
channelSubs.delete(subscriptionId);
|
|
113
|
-
if (channelSubs.size === 0) {
|
|
114
|
-
this.channelIndex.delete(sub.channel);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Get the number of active subscriptions.
|
|
121
|
-
*/
|
|
122
|
-
getSubscriptionCount(): number {
|
|
123
|
-
return this.subscriptions.size;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Get all active channel names.
|
|
128
|
-
*/
|
|
129
|
-
getChannels(): string[] {
|
|
130
|
-
return Array.from(this.channelIndex.keys());
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Check if an event matches a subscription's filters.
|
|
135
|
-
*/
|
|
136
|
-
private matchesSubscription(event: RealtimeEventPayload, sub: Subscription): boolean {
|
|
137
|
-
const opts = sub.options;
|
|
138
|
-
if (!opts) return true;
|
|
139
|
-
|
|
140
|
-
// Filter by object name
|
|
141
|
-
if (opts.object && event.object !== opts.object) {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Filter by event types
|
|
146
|
-
if (opts.eventTypes && opts.eventTypes.length > 0) {
|
|
147
|
-
if (!opts.eventTypes.includes(event.type)) {
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
export { RealtimeServicePlugin } from './realtime-service-plugin.js';
|
|
4
|
-
export type { RealtimeServicePluginOptions } from './realtime-service-plugin.js';
|
|
5
|
-
export { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';
|
|
6
|
-
export type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';
|
|
7
|
-
export { SysPresence } from './objects/index.js';
|
package/src/objects/index.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
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';
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
import { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js';
|
|
5
|
-
import type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js';
|
|
6
|
-
import { SysPresence } from './objects/index.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Configuration options for the RealtimeServicePlugin.
|
|
10
|
-
*/
|
|
11
|
-
export interface RealtimeServicePluginOptions {
|
|
12
|
-
/** Realtime adapter type (default: 'memory') */
|
|
13
|
-
adapter?: 'memory';
|
|
14
|
-
/** Options for the in-memory adapter */
|
|
15
|
-
memory?: InMemoryRealtimeAdapterOptions;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* RealtimeServicePlugin — Production IRealtimeService implementation.
|
|
20
|
-
*
|
|
21
|
-
* Registers a realtime pub/sub service with the kernel during the init phase.
|
|
22
|
-
* Currently supports in-memory pub/sub for single-process environments.
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* ```ts
|
|
26
|
-
* import { ObjectKernel } from '@objectstack/core';
|
|
27
|
-
* import { RealtimeServicePlugin } from '@objectstack/service-realtime';
|
|
28
|
-
*
|
|
29
|
-
* const kernel = new ObjectKernel();
|
|
30
|
-
* kernel.use(new RealtimeServicePlugin());
|
|
31
|
-
* await kernel.bootstrap();
|
|
32
|
-
*
|
|
33
|
-
* const realtime = kernel.getService('realtime');
|
|
34
|
-
* await realtime.subscribe('records', (event) => {
|
|
35
|
-
* console.log(event.type, event.payload);
|
|
36
|
-
* });
|
|
37
|
-
* ```
|
|
38
|
-
*/
|
|
39
|
-
export class RealtimeServicePlugin implements Plugin {
|
|
40
|
-
name = 'com.objectstack.service.realtime';
|
|
41
|
-
version = '1.0.0';
|
|
42
|
-
type = 'standard';
|
|
43
|
-
dependencies = ['com.objectstack.engine.objectql'];
|
|
44
|
-
|
|
45
|
-
private readonly options: RealtimeServicePluginOptions;
|
|
46
|
-
|
|
47
|
-
constructor(options: RealtimeServicePluginOptions = {}) {
|
|
48
|
-
this.options = { adapter: 'memory', ...options };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async init(ctx: PluginContext): Promise<void> {
|
|
52
|
-
const realtime = new InMemoryRealtimeAdapter(this.options.memory);
|
|
53
|
-
ctx.registerService('realtime', realtime);
|
|
54
|
-
|
|
55
|
-
// Register realtime system objects via the manifest service.
|
|
56
|
-
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
57
|
-
id: 'com.objectstack.service.realtime',
|
|
58
|
-
name: 'Realtime Service',
|
|
59
|
-
version: '1.0.0',
|
|
60
|
-
type: 'plugin',
|
|
61
|
-
namespace: 'sys',
|
|
62
|
-
objects: [SysPresence],
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
ctx.logger.info('RealtimeServicePlugin: registered in-memory realtime adapter');
|
|
66
|
-
}
|
|
67
|
-
}
|
package/tsconfig.json
DELETED