@objectstack/objectql 3.0.8 → 3.0.9

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.9",
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.9",
17
+ "@objectstack/spec": "3.0.9",
18
+ "@objectstack/types": "3.0.9"
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,8 @@ 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';
12
13
 
13
14
  // We import SchemaRegistry directly since this class lives in the same package
14
15
  import { SchemaRegistry } from './registry.js';
@@ -51,10 +52,20 @@ const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
51
52
  export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
52
53
  private engine: IDataEngine;
53
54
  private getServicesRegistry?: () => Map<string, any>;
55
+ private getFeedService?: () => IFeedService | undefined;
54
56
 
55
- constructor(engine: IDataEngine, getServicesRegistry?: () => Map<string, any>) {
57
+ constructor(engine: IDataEngine, getServicesRegistry?: () => Map<string, any>, getFeedService?: () => IFeedService | undefined) {
56
58
  this.engine = engine;
57
59
  this.getServicesRegistry = getServicesRegistry;
60
+ this.getFeedService = getFeedService;
61
+ }
62
+
63
+ private requireFeedService(): IFeedService {
64
+ const svc = this.getFeedService?.();
65
+ if (!svc) {
66
+ throw new Error('Feed service not available. Install and register service-feed to enable feed operations.');
67
+ }
68
+ return svc;
58
69
  }
59
70
 
60
71
  async getDiscovery() {
@@ -117,17 +128,45 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
117
128
  }
118
129
  }
119
130
 
131
+ // Add feed service status
132
+ if (registeredServices.has('feed')) {
133
+ services['feed'] = {
134
+ enabled: true,
135
+ status: 'available' as const,
136
+ route: '/api/v1/data',
137
+ provider: 'service-feed',
138
+ };
139
+ } else {
140
+ services['feed'] = {
141
+ enabled: false,
142
+ status: 'unavailable' as const,
143
+ message: 'Install service-feed to enable',
144
+ };
145
+ }
146
+
120
147
  const routes: ApiRoutes = {
121
148
  data: '/api/v1/data',
122
149
  metadata: '/api/v1/meta',
123
150
  ...optionalRoutes,
124
151
  };
125
152
 
153
+ // Build well-known capabilities from registered services
154
+ const capabilities: WellKnownCapabilities = {
155
+ feed: registeredServices.has('feed'),
156
+ comments: registeredServices.has('feed'),
157
+ automation: registeredServices.has('automation'),
158
+ cron: registeredServices.has('job'),
159
+ search: registeredServices.has('search'),
160
+ export: registeredServices.has('automation') || registeredServices.has('queue'),
161
+ chunkedUpload: registeredServices.has('file-storage'),
162
+ };
163
+
126
164
  return {
127
165
  version: '1.0',
128
166
  apiName: 'ObjectStack API',
129
167
  routes,
130
168
  services,
169
+ capabilities,
131
170
  };
132
171
  }
133
172
 
@@ -702,4 +741,155 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
702
741
  message: 'Saved to memory registry'
703
742
  };
704
743
  }
744
+
745
+ // ==========================================
746
+ // Feed Operations
747
+ // ==========================================
748
+
749
+ async listFeed(request: any): Promise<any> {
750
+ const svc = this.requireFeedService();
751
+ const result = await svc.listFeed({
752
+ object: request.object,
753
+ recordId: request.recordId,
754
+ filter: request.type,
755
+ limit: request.limit,
756
+ cursor: request.cursor,
757
+ });
758
+ return { success: true, data: result };
759
+ }
760
+
761
+ async createFeedItem(request: any): Promise<any> {
762
+ const svc = this.requireFeedService();
763
+ const item = await svc.createFeedItem({
764
+ object: request.object,
765
+ recordId: request.recordId,
766
+ type: request.type,
767
+ actor: { type: 'user', id: 'current_user' },
768
+ body: request.body,
769
+ mentions: request.mentions,
770
+ parentId: request.parentId,
771
+ visibility: request.visibility,
772
+ });
773
+ return { success: true, data: item };
774
+ }
775
+
776
+ async updateFeedItem(request: any): Promise<any> {
777
+ const svc = this.requireFeedService();
778
+ const item = await svc.updateFeedItem(request.feedId, {
779
+ body: request.body,
780
+ mentions: request.mentions,
781
+ visibility: request.visibility,
782
+ });
783
+ return { success: true, data: item };
784
+ }
785
+
786
+ async deleteFeedItem(request: any): Promise<any> {
787
+ const svc = this.requireFeedService();
788
+ await svc.deleteFeedItem(request.feedId);
789
+ return { success: true, data: { feedId: request.feedId } };
790
+ }
791
+
792
+ async addReaction(request: any): Promise<any> {
793
+ const svc = this.requireFeedService();
794
+ const reactions = await svc.addReaction(request.feedId, request.emoji, 'current_user');
795
+ return { success: true, data: { reactions } };
796
+ }
797
+
798
+ async removeReaction(request: any): Promise<any> {
799
+ const svc = this.requireFeedService();
800
+ const reactions = await svc.removeReaction(request.feedId, request.emoji, 'current_user');
801
+ return { success: true, data: { reactions } };
802
+ }
803
+
804
+ async pinFeedItem(request: any): Promise<any> {
805
+ const svc = this.requireFeedService();
806
+ const item = await svc.getFeedItem(request.feedId);
807
+ if (!item) throw new Error(`Feed item ${request.feedId} not found`);
808
+ // IFeedService doesn't have dedicated pin/unpin — use updateFeedItem to persist pin state
809
+ await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
810
+ return { success: true, data: { feedId: request.feedId, pinned: true, pinnedAt: new Date().toISOString() } };
811
+ }
812
+
813
+ async unpinFeedItem(request: any): Promise<any> {
814
+ const svc = this.requireFeedService();
815
+ const item = await svc.getFeedItem(request.feedId);
816
+ if (!item) throw new Error(`Feed item ${request.feedId} not found`);
817
+ await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
818
+ return { success: true, data: { feedId: request.feedId, pinned: false } };
819
+ }
820
+
821
+ async starFeedItem(request: any): Promise<any> {
822
+ const svc = this.requireFeedService();
823
+ const item = await svc.getFeedItem(request.feedId);
824
+ if (!item) throw new Error(`Feed item ${request.feedId} not found`);
825
+ // IFeedService doesn't have dedicated star/unstar — verify item exists then return state
826
+ await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
827
+ return { success: true, data: { feedId: request.feedId, starred: true, starredAt: new Date().toISOString() } };
828
+ }
829
+
830
+ async unstarFeedItem(request: any): Promise<any> {
831
+ const svc = this.requireFeedService();
832
+ const item = await svc.getFeedItem(request.feedId);
833
+ if (!item) throw new Error(`Feed item ${request.feedId} not found`);
834
+ await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
835
+ return { success: true, data: { feedId: request.feedId, starred: false } };
836
+ }
837
+
838
+ async searchFeed(request: any): Promise<any> {
839
+ const svc = this.requireFeedService();
840
+ // Search delegates to listFeed with filter since IFeedService doesn't have a dedicated search
841
+ const result = await svc.listFeed({
842
+ object: request.object,
843
+ recordId: request.recordId,
844
+ filter: request.type,
845
+ limit: request.limit,
846
+ cursor: request.cursor,
847
+ });
848
+ // Filter by query text in body
849
+ const queryLower = (request.query || '').toLowerCase();
850
+ const filtered = result.items.filter((item: any) =>
851
+ item.body?.toLowerCase().includes(queryLower)
852
+ );
853
+ return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
854
+ }
855
+
856
+ async getChangelog(request: any): Promise<any> {
857
+ const svc = this.requireFeedService();
858
+ // Changelog retrieves field_change type feed items
859
+ const result = await svc.listFeed({
860
+ object: request.object,
861
+ recordId: request.recordId,
862
+ filter: 'changes_only',
863
+ limit: request.limit,
864
+ cursor: request.cursor,
865
+ });
866
+ const entries = result.items.map((item: any) => ({
867
+ id: item.id,
868
+ object: item.object,
869
+ recordId: item.recordId,
870
+ actor: item.actor,
871
+ changes: item.changes || [],
872
+ timestamp: item.createdAt,
873
+ source: item.source,
874
+ }));
875
+ return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
876
+ }
877
+
878
+ async feedSubscribe(request: any): Promise<any> {
879
+ const svc = this.requireFeedService();
880
+ const subscription = await svc.subscribe({
881
+ object: request.object,
882
+ recordId: request.recordId,
883
+ userId: 'current_user',
884
+ events: request.events,
885
+ channels: request.channels,
886
+ });
887
+ return { success: true, data: subscription };
888
+ }
889
+
890
+ async feedUnsubscribe(request: any): Promise<any> {
891
+ const svc = this.requireFeedService();
892
+ const unsubscribed = await svc.unsubscribe(request.object, request.recordId, 'current_user');
893
+ return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
894
+ }
705
895
  }