@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/objectql",
3
- "version": "3.0.8",
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.8",
17
- "@objectstack/spec": "3.0.8",
18
- "@objectstack/types": "3.0.8"
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 not return capabilities field (removed use services instead)', async () => {
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 no longer exist in the response
148
- const keys = Object.keys(discovery);
149
- expect(keys).not.toContain('capabilities');
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
- if (typeof options.filters === 'string') {
272
- try { options.filter = JSON.parse(options.filters); delete options.filters; } catch { /* keep as-is */ }
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
  }