@objectstack/objectql 3.0.8 → 3.0.10
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.d.mts +50 -3
- package/dist/index.d.ts +50 -3
- package/dist/index.js +172 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/protocol-discovery.test.ts +65 -5
- package/src/protocol-feed.test.ts +303 -0
- package/src/protocol.ts +205 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/objectql",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.10",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Isomorphic ObjectQL Engine for ObjectStack",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "3.0.
|
|
17
|
-
"@objectstack/spec": "3.0.
|
|
18
|
-
"@objectstack/types": "3.0.
|
|
16
|
+
"@objectstack/core": "3.0.10",
|
|
17
|
+
"@objectstack/spec": "3.0.10",
|
|
18
|
+
"@objectstack/types": "3.0.10"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"typescript": "^5.0.0",
|
|
@@ -137,17 +137,77 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
|
|
|
137
137
|
expect(discovery.services.analytics.route).toBe('/api/v1/analytics');
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
it('should
|
|
140
|
+
it('should return capabilities field populated from registered services', async () => {
|
|
141
141
|
const mockServices = new Map<string, any>();
|
|
142
142
|
mockServices.set('workflow', {});
|
|
143
143
|
|
|
144
144
|
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
|
|
145
145
|
const discovery = await protocol.getDiscovery();
|
|
146
146
|
|
|
147
|
-
// capabilities field should
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Use services to check availability instead
|
|
147
|
+
// capabilities field should now exist in the response
|
|
148
|
+
expect(discovery.capabilities).toBeDefined();
|
|
149
|
+
// workflow is registered but doesn't map to a well-known capability directly
|
|
151
150
|
expect(discovery.services.workflow.enabled).toBe(true);
|
|
151
|
+
// All well-known capabilities should be false since workflow doesn't map to any
|
|
152
|
+
expect(discovery.capabilities!.feed).toBe(false);
|
|
153
|
+
expect(discovery.capabilities!.comments).toBe(false);
|
|
154
|
+
expect(discovery.capabilities!.automation).toBe(false);
|
|
155
|
+
expect(discovery.capabilities!.cron).toBe(false);
|
|
156
|
+
expect(discovery.capabilities!.search).toBe(false);
|
|
157
|
+
expect(discovery.capabilities!.export).toBe(false);
|
|
158
|
+
expect(discovery.capabilities!.chunkedUpload).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should set all capabilities to false when no services are registered', async () => {
|
|
162
|
+
protocol = new ObjectStackProtocolImplementation(engine);
|
|
163
|
+
const discovery = await protocol.getDiscovery();
|
|
164
|
+
|
|
165
|
+
expect(discovery.capabilities).toBeDefined();
|
|
166
|
+
expect(discovery.capabilities!.feed).toBe(false);
|
|
167
|
+
expect(discovery.capabilities!.comments).toBe(false);
|
|
168
|
+
expect(discovery.capabilities!.automation).toBe(false);
|
|
169
|
+
expect(discovery.capabilities!.cron).toBe(false);
|
|
170
|
+
expect(discovery.capabilities!.search).toBe(false);
|
|
171
|
+
expect(discovery.capabilities!.export).toBe(false);
|
|
172
|
+
expect(discovery.capabilities!.chunkedUpload).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should dynamically set capabilities based on registered services', async () => {
|
|
176
|
+
const mockServices = new Map<string, any>();
|
|
177
|
+
mockServices.set('feed', {});
|
|
178
|
+
mockServices.set('automation', {});
|
|
179
|
+
mockServices.set('search', {});
|
|
180
|
+
mockServices.set('file-storage', {});
|
|
181
|
+
|
|
182
|
+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
|
|
183
|
+
const discovery = await protocol.getDiscovery();
|
|
184
|
+
|
|
185
|
+
expect(discovery.capabilities!.feed).toBe(true);
|
|
186
|
+
expect(discovery.capabilities!.comments).toBe(true);
|
|
187
|
+
expect(discovery.capabilities!.automation).toBe(true);
|
|
188
|
+
expect(discovery.capabilities!.cron).toBe(false);
|
|
189
|
+
expect(discovery.capabilities!.search).toBe(true);
|
|
190
|
+
expect(discovery.capabilities!.export).toBe(true);
|
|
191
|
+
expect(discovery.capabilities!.chunkedUpload).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should enable cron capability when job service is registered', async () => {
|
|
195
|
+
const mockServices = new Map<string, any>();
|
|
196
|
+
mockServices.set('job', {});
|
|
197
|
+
|
|
198
|
+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
|
|
199
|
+
const discovery = await protocol.getDiscovery();
|
|
200
|
+
|
|
201
|
+
expect(discovery.capabilities!.cron).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should enable export capability when queue service is registered', async () => {
|
|
205
|
+
const mockServices = new Map<string, any>();
|
|
206
|
+
mockServices.set('queue', {});
|
|
207
|
+
|
|
208
|
+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
|
|
209
|
+
const discovery = await protocol.getDiscovery();
|
|
210
|
+
|
|
211
|
+
expect(discovery.capabilities!.export).toBe(true);
|
|
152
212
|
});
|
|
153
213
|
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
|
+
import { ObjectStackProtocolImplementation } from './protocol.js';
|
|
5
|
+
import { ObjectQL } from './engine.js';
|
|
6
|
+
import type { IFeedService } from '@objectstack/spec/contracts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mock IFeedService for testing feed route handlers.
|
|
10
|
+
*/
|
|
11
|
+
function createMockFeedService(): IFeedService {
|
|
12
|
+
return {
|
|
13
|
+
listFeed: vi.fn().mockResolvedValue({
|
|
14
|
+
items: [{ id: 'feed_1', type: 'comment', body: 'Hello world', createdAt: '2026-01-01T00:00:00Z' }],
|
|
15
|
+
total: 1,
|
|
16
|
+
hasMore: false,
|
|
17
|
+
}),
|
|
18
|
+
createFeedItem: vi.fn().mockResolvedValue({
|
|
19
|
+
id: 'feed_new',
|
|
20
|
+
type: 'comment',
|
|
21
|
+
body: 'New comment',
|
|
22
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
23
|
+
}),
|
|
24
|
+
updateFeedItem: vi.fn().mockResolvedValue({
|
|
25
|
+
id: 'feed_1',
|
|
26
|
+
type: 'comment',
|
|
27
|
+
body: 'Updated comment',
|
|
28
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
29
|
+
}),
|
|
30
|
+
deleteFeedItem: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
getFeedItem: vi.fn().mockResolvedValue({
|
|
32
|
+
id: 'feed_1',
|
|
33
|
+
type: 'comment',
|
|
34
|
+
body: 'Hello world',
|
|
35
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
36
|
+
}),
|
|
37
|
+
addReaction: vi.fn().mockResolvedValue([
|
|
38
|
+
{ emoji: '👍', users: ['current_user'], count: 1 },
|
|
39
|
+
]),
|
|
40
|
+
removeReaction: vi.fn().mockResolvedValue([]),
|
|
41
|
+
subscribe: vi.fn().mockResolvedValue({
|
|
42
|
+
id: 'sub_1',
|
|
43
|
+
object: 'account',
|
|
44
|
+
recordId: 'rec_123',
|
|
45
|
+
userId: 'current_user',
|
|
46
|
+
events: ['all'],
|
|
47
|
+
channels: ['in_app'],
|
|
48
|
+
}),
|
|
49
|
+
unsubscribe: vi.fn().mockResolvedValue(true),
|
|
50
|
+
getSubscription: vi.fn().mockResolvedValue(null),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('ObjectStackProtocolImplementation - Feed Operations', () => {
|
|
55
|
+
let protocol: ObjectStackProtocolImplementation;
|
|
56
|
+
let engine: ObjectQL;
|
|
57
|
+
let feedService: IFeedService;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
engine = new ObjectQL();
|
|
61
|
+
feedService = createMockFeedService();
|
|
62
|
+
protocol = new ObjectStackProtocolImplementation(engine, undefined, () => feedService);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ==========================================
|
|
66
|
+
// Discovery
|
|
67
|
+
// ==========================================
|
|
68
|
+
|
|
69
|
+
it('should show feed service as unavailable when not registered', async () => {
|
|
70
|
+
const protocolNoFeed = new ObjectStackProtocolImplementation(engine);
|
|
71
|
+
const discovery = await protocolNoFeed.getDiscovery();
|
|
72
|
+
|
|
73
|
+
expect(discovery.services.feed).toBeDefined();
|
|
74
|
+
expect(discovery.services.feed.enabled).toBe(false);
|
|
75
|
+
expect(discovery.services.feed.status).toBe('unavailable');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should show feed service as available when registered', async () => {
|
|
79
|
+
const mockServices = new Map<string, any>();
|
|
80
|
+
mockServices.set('feed', {});
|
|
81
|
+
const protocolWithFeed = new ObjectStackProtocolImplementation(engine, () => mockServices, () => feedService);
|
|
82
|
+
const discovery = await protocolWithFeed.getDiscovery();
|
|
83
|
+
|
|
84
|
+
expect(discovery.services.feed).toBeDefined();
|
|
85
|
+
expect(discovery.services.feed.enabled).toBe(true);
|
|
86
|
+
expect(discovery.services.feed.status).toBe('available');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ==========================================
|
|
90
|
+
// Feed CRUD
|
|
91
|
+
// ==========================================
|
|
92
|
+
|
|
93
|
+
it('listFeed should delegate to feedService.listFeed', async () => {
|
|
94
|
+
const result = await protocol.listFeed({ object: 'account', recordId: 'rec_123' });
|
|
95
|
+
|
|
96
|
+
expect(result.success).toBe(true);
|
|
97
|
+
expect(result.data.items).toHaveLength(1);
|
|
98
|
+
expect(feedService.listFeed).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({ object: 'account', recordId: 'rec_123' })
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('createFeedItem should delegate to feedService.createFeedItem', async () => {
|
|
104
|
+
const result = await protocol.createFeedItem({
|
|
105
|
+
object: 'account',
|
|
106
|
+
recordId: 'rec_123',
|
|
107
|
+
type: 'comment',
|
|
108
|
+
body: 'New comment',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
expect(result.data.id).toBe('feed_new');
|
|
113
|
+
expect(feedService.createFeedItem).toHaveBeenCalledWith(
|
|
114
|
+
expect.objectContaining({ object: 'account', recordId: 'rec_123', type: 'comment', body: 'New comment' })
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('updateFeedItem should delegate to feedService.updateFeedItem', async () => {
|
|
119
|
+
const result = await protocol.updateFeedItem({
|
|
120
|
+
object: 'account',
|
|
121
|
+
recordId: 'rec_123',
|
|
122
|
+
feedId: 'feed_1',
|
|
123
|
+
body: 'Updated',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(true);
|
|
127
|
+
expect(result.data.body).toBe('Updated comment');
|
|
128
|
+
expect(feedService.updateFeedItem).toHaveBeenCalledWith('feed_1', expect.objectContaining({ body: 'Updated' }));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('deleteFeedItem should delegate to feedService.deleteFeedItem', async () => {
|
|
132
|
+
const result = await protocol.deleteFeedItem({
|
|
133
|
+
object: 'account',
|
|
134
|
+
recordId: 'rec_123',
|
|
135
|
+
feedId: 'feed_1',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
expect(result.data.feedId).toBe('feed_1');
|
|
140
|
+
expect(feedService.deleteFeedItem).toHaveBeenCalledWith('feed_1');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ==========================================
|
|
144
|
+
// Reactions
|
|
145
|
+
// ==========================================
|
|
146
|
+
|
|
147
|
+
it('addReaction should delegate to feedService.addReaction', async () => {
|
|
148
|
+
const result = await protocol.addReaction({
|
|
149
|
+
object: 'account',
|
|
150
|
+
recordId: 'rec_123',
|
|
151
|
+
feedId: 'feed_1',
|
|
152
|
+
emoji: '👍',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result.success).toBe(true);
|
|
156
|
+
expect(result.data.reactions).toHaveLength(1);
|
|
157
|
+
expect(feedService.addReaction).toHaveBeenCalledWith('feed_1', '👍', 'current_user');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('removeReaction should delegate to feedService.removeReaction', async () => {
|
|
161
|
+
const result = await protocol.removeReaction({
|
|
162
|
+
object: 'account',
|
|
163
|
+
recordId: 'rec_123',
|
|
164
|
+
feedId: 'feed_1',
|
|
165
|
+
emoji: '👍',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.success).toBe(true);
|
|
169
|
+
expect(result.data.reactions).toHaveLength(0);
|
|
170
|
+
expect(feedService.removeReaction).toHaveBeenCalledWith('feed_1', '👍', 'current_user');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ==========================================
|
|
174
|
+
// Pin / Star
|
|
175
|
+
// ==========================================
|
|
176
|
+
|
|
177
|
+
it('pinFeedItem should verify item exists and return pinned status', async () => {
|
|
178
|
+
const result = await protocol.pinFeedItem({
|
|
179
|
+
object: 'account',
|
|
180
|
+
recordId: 'rec_123',
|
|
181
|
+
feedId: 'feed_1',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(result.success).toBe(true);
|
|
185
|
+
expect(result.data.feedId).toBe('feed_1');
|
|
186
|
+
expect(result.data.pinned).toBe(true);
|
|
187
|
+
expect(result.data.pinnedAt).toBeDefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('unpinFeedItem should verify item exists and return unpinned status', async () => {
|
|
191
|
+
const result = await protocol.unpinFeedItem({
|
|
192
|
+
object: 'account',
|
|
193
|
+
recordId: 'rec_123',
|
|
194
|
+
feedId: 'feed_1',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(result.success).toBe(true);
|
|
198
|
+
expect(result.data.feedId).toBe('feed_1');
|
|
199
|
+
expect(result.data.pinned).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('starFeedItem should verify item exists and return starred status', async () => {
|
|
203
|
+
const result = await protocol.starFeedItem({
|
|
204
|
+
object: 'account',
|
|
205
|
+
recordId: 'rec_123',
|
|
206
|
+
feedId: 'feed_1',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.success).toBe(true);
|
|
210
|
+
expect(result.data.feedId).toBe('feed_1');
|
|
211
|
+
expect(result.data.starred).toBe(true);
|
|
212
|
+
expect(result.data.starredAt).toBeDefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('unstarFeedItem should verify item exists and return unstarred status', async () => {
|
|
216
|
+
const result = await protocol.unstarFeedItem({
|
|
217
|
+
object: 'account',
|
|
218
|
+
recordId: 'rec_123',
|
|
219
|
+
feedId: 'feed_1',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.success).toBe(true);
|
|
223
|
+
expect(result.data.feedId).toBe('feed_1');
|
|
224
|
+
expect(result.data.starred).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ==========================================
|
|
228
|
+
// Search & Changelog
|
|
229
|
+
// ==========================================
|
|
230
|
+
|
|
231
|
+
it('searchFeed should filter items by query text', async () => {
|
|
232
|
+
const result = await protocol.searchFeed({
|
|
233
|
+
object: 'account',
|
|
234
|
+
recordId: 'rec_123',
|
|
235
|
+
query: 'hello',
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(true);
|
|
239
|
+
expect(result.data.items).toHaveLength(1);
|
|
240
|
+
expect(result.data.hasMore).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('getChangelog should return field change entries', async () => {
|
|
244
|
+
const result = await protocol.getChangelog({
|
|
245
|
+
object: 'account',
|
|
246
|
+
recordId: 'rec_123',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.success).toBe(true);
|
|
250
|
+
expect(result.data.entries).toBeDefined();
|
|
251
|
+
expect(feedService.listFeed).toHaveBeenCalledWith(
|
|
252
|
+
expect.objectContaining({ filter: 'changes_only' })
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ==========================================
|
|
257
|
+
// Subscriptions
|
|
258
|
+
// ==========================================
|
|
259
|
+
|
|
260
|
+
it('feedSubscribe should delegate to feedService.subscribe', async () => {
|
|
261
|
+
const result = await protocol.feedSubscribe({
|
|
262
|
+
object: 'account',
|
|
263
|
+
recordId: 'rec_123',
|
|
264
|
+
events: ['all'],
|
|
265
|
+
channels: ['in_app'],
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(result.success).toBe(true);
|
|
269
|
+
expect(result.data.object).toBe('account');
|
|
270
|
+
expect(feedService.subscribe).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({ object: 'account', recordId: 'rec_123' })
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('feedUnsubscribe should delegate to feedService.unsubscribe', async () => {
|
|
276
|
+
const result = await protocol.feedUnsubscribe({
|
|
277
|
+
object: 'account',
|
|
278
|
+
recordId: 'rec_123',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(result.success).toBe(true);
|
|
282
|
+
expect(result.data.unsubscribed).toBe(true);
|
|
283
|
+
expect(feedService.unsubscribe).toHaveBeenCalledWith('account', 'rec_123', 'current_user');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ==========================================
|
|
287
|
+
// Error handling
|
|
288
|
+
// ==========================================
|
|
289
|
+
|
|
290
|
+
it('should throw when feed service is not available', async () => {
|
|
291
|
+
const protocolNoFeed = new ObjectStackProtocolImplementation(engine);
|
|
292
|
+
|
|
293
|
+
await expect(protocolNoFeed.listFeed({ object: 'a', recordId: 'b' }))
|
|
294
|
+
.rejects.toThrow('Feed service not available');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('pinFeedItem should throw when feed item not found', async () => {
|
|
298
|
+
(feedService.getFeedItem as any).mockResolvedValue(null);
|
|
299
|
+
|
|
300
|
+
await expect(protocol.pinFeedItem({ object: 'a', recordId: 'b', feedId: 'nonexistent' }))
|
|
301
|
+
.rejects.toThrow('Feed item nonexistent not found');
|
|
302
|
+
});
|
|
303
|
+
});
|
package/src/protocol.ts
CHANGED
|
@@ -8,7 +8,9 @@ import type {
|
|
|
8
8
|
UpdateManyDataRequest,
|
|
9
9
|
DeleteManyDataRequest
|
|
10
10
|
} from '@objectstack/spec/api';
|
|
11
|
-
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes } from '@objectstack/spec/api';
|
|
11
|
+
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
|
|
12
|
+
import type { IFeedService } from '@objectstack/spec/contracts';
|
|
13
|
+
import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
|
|
12
14
|
|
|
13
15
|
// We import SchemaRegistry directly since this class lives in the same package
|
|
14
16
|
import { SchemaRegistry } from './registry.js';
|
|
@@ -51,10 +53,20 @@ const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
|
|
|
51
53
|
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
52
54
|
private engine: IDataEngine;
|
|
53
55
|
private getServicesRegistry?: () => Map<string, any>;
|
|
56
|
+
private getFeedService?: () => IFeedService | undefined;
|
|
54
57
|
|
|
55
|
-
constructor(engine: IDataEngine, getServicesRegistry?: () => Map<string, any
|
|
58
|
+
constructor(engine: IDataEngine, getServicesRegistry?: () => Map<string, any>, getFeedService?: () => IFeedService | undefined) {
|
|
56
59
|
this.engine = engine;
|
|
57
60
|
this.getServicesRegistry = getServicesRegistry;
|
|
61
|
+
this.getFeedService = getFeedService;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private requireFeedService(): IFeedService {
|
|
65
|
+
const svc = this.getFeedService?.();
|
|
66
|
+
if (!svc) {
|
|
67
|
+
throw new Error('Feed service not available. Install and register service-feed to enable feed operations.');
|
|
68
|
+
}
|
|
69
|
+
return svc;
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
async getDiscovery() {
|
|
@@ -117,17 +129,45 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
117
129
|
}
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
// Add feed service status
|
|
133
|
+
if (registeredServices.has('feed')) {
|
|
134
|
+
services['feed'] = {
|
|
135
|
+
enabled: true,
|
|
136
|
+
status: 'available' as const,
|
|
137
|
+
route: '/api/v1/data',
|
|
138
|
+
provider: 'service-feed',
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
services['feed'] = {
|
|
142
|
+
enabled: false,
|
|
143
|
+
status: 'unavailable' as const,
|
|
144
|
+
message: 'Install service-feed to enable',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
const routes: ApiRoutes = {
|
|
121
149
|
data: '/api/v1/data',
|
|
122
150
|
metadata: '/api/v1/meta',
|
|
123
151
|
...optionalRoutes,
|
|
124
152
|
};
|
|
125
153
|
|
|
154
|
+
// Build well-known capabilities from registered services
|
|
155
|
+
const capabilities: WellKnownCapabilities = {
|
|
156
|
+
feed: registeredServices.has('feed'),
|
|
157
|
+
comments: registeredServices.has('feed'),
|
|
158
|
+
automation: registeredServices.has('automation'),
|
|
159
|
+
cron: registeredServices.has('job'),
|
|
160
|
+
search: registeredServices.has('search'),
|
|
161
|
+
export: registeredServices.has('automation') || registeredServices.has('queue'),
|
|
162
|
+
chunkedUpload: registeredServices.has('file-storage'),
|
|
163
|
+
};
|
|
164
|
+
|
|
126
165
|
return {
|
|
127
166
|
version: '1.0',
|
|
128
167
|
apiName: 'ObjectStack API',
|
|
129
168
|
routes,
|
|
130
169
|
services,
|
|
170
|
+
capabilities,
|
|
131
171
|
};
|
|
132
172
|
}
|
|
133
173
|
|
|
@@ -264,12 +304,22 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
264
304
|
delete options.orderBy;
|
|
265
305
|
}
|
|
266
306
|
|
|
307
|
+
// Filter: normalize `filter`/`filters` (plural) → `filter` (singular, canonical)
|
|
308
|
+
// Accept both `filter` and `filters` for backward compatibility.
|
|
309
|
+
if (options.filters !== undefined && options.filter === undefined) {
|
|
310
|
+
options.filter = options.filters;
|
|
311
|
+
}
|
|
312
|
+
delete options.filters;
|
|
313
|
+
|
|
267
314
|
// Filter: JSON string → object
|
|
268
315
|
if (typeof options.filter === 'string') {
|
|
269
316
|
try { options.filter = JSON.parse(options.filter); } catch { /* keep as-is */ }
|
|
270
317
|
}
|
|
271
|
-
|
|
272
|
-
|
|
318
|
+
|
|
319
|
+
// Filter AST array → FilterCondition object
|
|
320
|
+
// Converts ["and", ["field", "=", "val"], ...] to { $and: [{ field: "val" }, ...] }
|
|
321
|
+
if (isFilterAST(options.filter)) {
|
|
322
|
+
options.filter = parseFilterAST(options.filter);
|
|
273
323
|
}
|
|
274
324
|
|
|
275
325
|
// Populate: comma-separated string → array
|
|
@@ -702,4 +752,155 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
702
752
|
message: 'Saved to memory registry'
|
|
703
753
|
};
|
|
704
754
|
}
|
|
755
|
+
|
|
756
|
+
// ==========================================
|
|
757
|
+
// Feed Operations
|
|
758
|
+
// ==========================================
|
|
759
|
+
|
|
760
|
+
async listFeed(request: any): Promise<any> {
|
|
761
|
+
const svc = this.requireFeedService();
|
|
762
|
+
const result = await svc.listFeed({
|
|
763
|
+
object: request.object,
|
|
764
|
+
recordId: request.recordId,
|
|
765
|
+
filter: request.type,
|
|
766
|
+
limit: request.limit,
|
|
767
|
+
cursor: request.cursor,
|
|
768
|
+
});
|
|
769
|
+
return { success: true, data: result };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async createFeedItem(request: any): Promise<any> {
|
|
773
|
+
const svc = this.requireFeedService();
|
|
774
|
+
const item = await svc.createFeedItem({
|
|
775
|
+
object: request.object,
|
|
776
|
+
recordId: request.recordId,
|
|
777
|
+
type: request.type,
|
|
778
|
+
actor: { type: 'user', id: 'current_user' },
|
|
779
|
+
body: request.body,
|
|
780
|
+
mentions: request.mentions,
|
|
781
|
+
parentId: request.parentId,
|
|
782
|
+
visibility: request.visibility,
|
|
783
|
+
});
|
|
784
|
+
return { success: true, data: item };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async updateFeedItem(request: any): Promise<any> {
|
|
788
|
+
const svc = this.requireFeedService();
|
|
789
|
+
const item = await svc.updateFeedItem(request.feedId, {
|
|
790
|
+
body: request.body,
|
|
791
|
+
mentions: request.mentions,
|
|
792
|
+
visibility: request.visibility,
|
|
793
|
+
});
|
|
794
|
+
return { success: true, data: item };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async deleteFeedItem(request: any): Promise<any> {
|
|
798
|
+
const svc = this.requireFeedService();
|
|
799
|
+
await svc.deleteFeedItem(request.feedId);
|
|
800
|
+
return { success: true, data: { feedId: request.feedId } };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async addReaction(request: any): Promise<any> {
|
|
804
|
+
const svc = this.requireFeedService();
|
|
805
|
+
const reactions = await svc.addReaction(request.feedId, request.emoji, 'current_user');
|
|
806
|
+
return { success: true, data: { reactions } };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async removeReaction(request: any): Promise<any> {
|
|
810
|
+
const svc = this.requireFeedService();
|
|
811
|
+
const reactions = await svc.removeReaction(request.feedId, request.emoji, 'current_user');
|
|
812
|
+
return { success: true, data: { reactions } };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async pinFeedItem(request: any): Promise<any> {
|
|
816
|
+
const svc = this.requireFeedService();
|
|
817
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
818
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
819
|
+
// IFeedService doesn't have dedicated pin/unpin — use updateFeedItem to persist pin state
|
|
820
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
821
|
+
return { success: true, data: { feedId: request.feedId, pinned: true, pinnedAt: new Date().toISOString() } };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async unpinFeedItem(request: any): Promise<any> {
|
|
825
|
+
const svc = this.requireFeedService();
|
|
826
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
827
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
828
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
829
|
+
return { success: true, data: { feedId: request.feedId, pinned: false } };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async starFeedItem(request: any): Promise<any> {
|
|
833
|
+
const svc = this.requireFeedService();
|
|
834
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
835
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
836
|
+
// IFeedService doesn't have dedicated star/unstar — verify item exists then return state
|
|
837
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
838
|
+
return { success: true, data: { feedId: request.feedId, starred: true, starredAt: new Date().toISOString() } };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async unstarFeedItem(request: any): Promise<any> {
|
|
842
|
+
const svc = this.requireFeedService();
|
|
843
|
+
const item = await svc.getFeedItem(request.feedId);
|
|
844
|
+
if (!item) throw new Error(`Feed item ${request.feedId} not found`);
|
|
845
|
+
await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
|
|
846
|
+
return { success: true, data: { feedId: request.feedId, starred: false } };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async searchFeed(request: any): Promise<any> {
|
|
850
|
+
const svc = this.requireFeedService();
|
|
851
|
+
// Search delegates to listFeed with filter since IFeedService doesn't have a dedicated search
|
|
852
|
+
const result = await svc.listFeed({
|
|
853
|
+
object: request.object,
|
|
854
|
+
recordId: request.recordId,
|
|
855
|
+
filter: request.type,
|
|
856
|
+
limit: request.limit,
|
|
857
|
+
cursor: request.cursor,
|
|
858
|
+
});
|
|
859
|
+
// Filter by query text in body
|
|
860
|
+
const queryLower = (request.query || '').toLowerCase();
|
|
861
|
+
const filtered = result.items.filter((item: any) =>
|
|
862
|
+
item.body?.toLowerCase().includes(queryLower)
|
|
863
|
+
);
|
|
864
|
+
return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async getChangelog(request: any): Promise<any> {
|
|
868
|
+
const svc = this.requireFeedService();
|
|
869
|
+
// Changelog retrieves field_change type feed items
|
|
870
|
+
const result = await svc.listFeed({
|
|
871
|
+
object: request.object,
|
|
872
|
+
recordId: request.recordId,
|
|
873
|
+
filter: 'changes_only',
|
|
874
|
+
limit: request.limit,
|
|
875
|
+
cursor: request.cursor,
|
|
876
|
+
});
|
|
877
|
+
const entries = result.items.map((item: any) => ({
|
|
878
|
+
id: item.id,
|
|
879
|
+
object: item.object,
|
|
880
|
+
recordId: item.recordId,
|
|
881
|
+
actor: item.actor,
|
|
882
|
+
changes: item.changes || [],
|
|
883
|
+
timestamp: item.createdAt,
|
|
884
|
+
source: item.source,
|
|
885
|
+
}));
|
|
886
|
+
return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async feedSubscribe(request: any): Promise<any> {
|
|
890
|
+
const svc = this.requireFeedService();
|
|
891
|
+
const subscription = await svc.subscribe({
|
|
892
|
+
object: request.object,
|
|
893
|
+
recordId: request.recordId,
|
|
894
|
+
userId: 'current_user',
|
|
895
|
+
events: request.events,
|
|
896
|
+
channels: request.channels,
|
|
897
|
+
});
|
|
898
|
+
return { success: true, data: subscription };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async feedUnsubscribe(request: any): Promise<any> {
|
|
902
|
+
const svc = this.requireFeedService();
|
|
903
|
+
const unsubscribed = await svc.unsubscribe(request.object, request.recordId, 'current_user');
|
|
904
|
+
return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
|
|
905
|
+
}
|
|
705
906
|
}
|