@objectstack/service-feed 3.0.7

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.
@@ -0,0 +1,321 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ IFeedService,
5
+ CreateFeedItemInput,
6
+ UpdateFeedItemInput,
7
+ ListFeedOptions,
8
+ FeedListResult,
9
+ SubscribeInput,
10
+ } from '@objectstack/spec/contracts';
11
+ import type { FeedItem, Reaction } from '@objectstack/spec/data';
12
+ import type { RecordSubscription } from '@objectstack/spec/data';
13
+
14
+ /**
15
+ * Configuration options for InMemoryFeedAdapter.
16
+ */
17
+ export interface InMemoryFeedAdapterOptions {
18
+ /** Maximum number of feed items to store (0 = unlimited) */
19
+ maxItems?: number;
20
+ }
21
+
22
+ /**
23
+ * In-memory Feed/Chatter adapter implementing IFeedService.
24
+ *
25
+ * Uses Map-backed stores for feed items, reactions, and subscriptions.
26
+ * Supports feed CRUD, emoji reactions, threaded replies, and record subscriptions.
27
+ *
28
+ * Suitable for single-process environments, development, and testing.
29
+ * For production deployments, use a database-backed adapter.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const feed = new InMemoryFeedAdapter();
34
+ *
35
+ * const item = await feed.createFeedItem({
36
+ * object: 'account',
37
+ * recordId: 'rec_123',
38
+ * type: 'comment',
39
+ * actor: { type: 'user', id: 'user_1', name: 'Alice' },
40
+ * body: 'Great progress!',
41
+ * });
42
+ *
43
+ * const list = await feed.listFeed({ object: 'account', recordId: 'rec_123' });
44
+ * ```
45
+ */
46
+ export class InMemoryFeedAdapter implements IFeedService {
47
+ private readonly items = new Map<string, FeedItem>();
48
+ private counter = 0;
49
+ private readonly subscriptions = new Map<string, RecordSubscription>();
50
+ private readonly maxItems: number;
51
+
52
+ constructor(options: InMemoryFeedAdapterOptions = {}) {
53
+ this.maxItems = options.maxItems ?? 0;
54
+ }
55
+
56
+ async listFeed(options: ListFeedOptions): Promise<FeedListResult> {
57
+ let items = Array.from(this.items.values()).filter(
58
+ (item) => item.object === options.object && item.recordId === options.recordId,
59
+ );
60
+
61
+ // Apply filter
62
+ if (options.filter && options.filter !== 'all') {
63
+ items = items.filter((item) => {
64
+ switch (options.filter) {
65
+ case 'comments_only':
66
+ return item.type === 'comment';
67
+ case 'changes_only':
68
+ return item.type === 'field_change';
69
+ case 'tasks_only':
70
+ return item.type === 'task';
71
+ default:
72
+ return true;
73
+ }
74
+ });
75
+ }
76
+
77
+ // Sort reverse chronological (stable: break ties by ID descending)
78
+ items.sort((a, b) => {
79
+ const timeDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
80
+ if (timeDiff !== 0) return timeDiff;
81
+ return b.id < a.id ? -1 : b.id > a.id ? 1 : 0;
82
+ });
83
+
84
+ const total = items.length;
85
+ const limit = options.limit ?? 20;
86
+
87
+ // Cursor-based pagination
88
+ let startIndex = 0;
89
+ if (options.cursor) {
90
+ const cursorIndex = items.findIndex((item) => item.id === options.cursor);
91
+ if (cursorIndex >= 0) {
92
+ startIndex = cursorIndex + 1;
93
+ }
94
+ }
95
+
96
+ const page = items.slice(startIndex, startIndex + limit);
97
+ const hasMore = startIndex + limit < total;
98
+
99
+ return {
100
+ items: page,
101
+ total,
102
+ nextCursor: hasMore && page.length > 0 ? page[page.length - 1].id : undefined,
103
+ hasMore,
104
+ };
105
+ }
106
+
107
+ async createFeedItem(input: CreateFeedItemInput): Promise<FeedItem> {
108
+ if (this.maxItems > 0 && this.items.size >= this.maxItems) {
109
+ throw new Error(
110
+ `Maximum feed item limit reached (${this.maxItems}). ` +
111
+ 'Delete existing items before adding new ones.',
112
+ );
113
+ }
114
+
115
+ const id = `feed_${++this.counter}`;
116
+ const now = new Date().toISOString();
117
+
118
+ // Increment parent reply count if threading
119
+ if (input.parentId) {
120
+ const parent = this.items.get(input.parentId);
121
+ if (!parent) {
122
+ throw new Error(`Parent feed item not found: ${input.parentId}`);
123
+ }
124
+ const updatedParent: FeedItem = {
125
+ ...parent,
126
+ replyCount: (parent.replyCount ?? 0) + 1,
127
+ updatedAt: now,
128
+ };
129
+ this.items.set(parent.id, updatedParent);
130
+ }
131
+
132
+ const item: FeedItem = {
133
+ id,
134
+ type: input.type as FeedItem['type'],
135
+ object: input.object,
136
+ recordId: input.recordId,
137
+ actor: {
138
+ type: input.actor.type,
139
+ id: input.actor.id,
140
+ ...(input.actor.name ? { name: input.actor.name } : {}),
141
+ ...(input.actor.avatarUrl ? { avatarUrl: input.actor.avatarUrl } : {}),
142
+ },
143
+ ...(input.body !== undefined ? { body: input.body } : {}),
144
+ ...(input.mentions ? { mentions: input.mentions } : {}),
145
+ ...(input.changes ? { changes: input.changes } : {}),
146
+ ...(input.parentId ? { parentId: input.parentId } : {}),
147
+ visibility: input.visibility ?? 'public',
148
+ replyCount: 0,
149
+ isEdited: false,
150
+ createdAt: now,
151
+ };
152
+
153
+ this.items.set(id, item);
154
+ return item;
155
+ }
156
+
157
+ async updateFeedItem(feedId: string, input: UpdateFeedItemInput): Promise<FeedItem> {
158
+ const existing = this.items.get(feedId);
159
+ if (!existing) {
160
+ throw new Error(`Feed item not found: ${feedId}`);
161
+ }
162
+
163
+ const now = new Date().toISOString();
164
+ const updated: FeedItem = {
165
+ ...existing,
166
+ ...(input.body !== undefined ? { body: input.body } : {}),
167
+ ...(input.mentions !== undefined ? { mentions: input.mentions } : {}),
168
+ ...(input.visibility !== undefined ? { visibility: input.visibility } : {}),
169
+ updatedAt: now,
170
+ editedAt: now,
171
+ isEdited: true,
172
+ };
173
+
174
+ this.items.set(feedId, updated);
175
+ return updated;
176
+ }
177
+
178
+ async deleteFeedItem(feedId: string): Promise<void> {
179
+ const item = this.items.get(feedId);
180
+ if (!item) {
181
+ throw new Error(`Feed item not found: ${feedId}`);
182
+ }
183
+
184
+ // Decrement parent reply count if threaded
185
+ if (item.parentId) {
186
+ const parent = this.items.get(item.parentId);
187
+ if (parent) {
188
+ const updatedParent: FeedItem = {
189
+ ...parent,
190
+ replyCount: Math.max(0, (parent.replyCount ?? 0) - 1),
191
+ };
192
+ this.items.set(parent.id, updatedParent);
193
+ }
194
+ }
195
+
196
+ this.items.delete(feedId);
197
+ }
198
+
199
+ async getFeedItem(feedId: string): Promise<FeedItem | null> {
200
+ return this.items.get(feedId) ?? null;
201
+ }
202
+
203
+ async addReaction(feedId: string, emoji: string, userId: string): Promise<Reaction[]> {
204
+ const item = this.items.get(feedId);
205
+ if (!item) {
206
+ throw new Error(`Feed item not found: ${feedId}`);
207
+ }
208
+
209
+ const reactions = [...(item.reactions ?? [])];
210
+ const existing = reactions.find((r) => r.emoji === emoji);
211
+
212
+ if (existing) {
213
+ if (existing.userIds.includes(userId)) {
214
+ throw new Error(`Reaction already exists: ${emoji} by ${userId}`);
215
+ }
216
+ existing.userIds = [...existing.userIds, userId];
217
+ existing.count = existing.userIds.length;
218
+ } else {
219
+ reactions.push({ emoji, userIds: [userId], count: 1 });
220
+ }
221
+
222
+ const updated: FeedItem = { ...item, reactions };
223
+ this.items.set(feedId, updated);
224
+ return reactions;
225
+ }
226
+
227
+ async removeReaction(feedId: string, emoji: string, userId: string): Promise<Reaction[]> {
228
+ const item = this.items.get(feedId);
229
+ if (!item) {
230
+ throw new Error(`Feed item not found: ${feedId}`);
231
+ }
232
+
233
+ let reactions = [...(item.reactions ?? [])];
234
+ const existing = reactions.find((r) => r.emoji === emoji);
235
+
236
+ if (!existing || !existing.userIds.includes(userId)) {
237
+ throw new Error(`Reaction not found: ${emoji} by ${userId}`);
238
+ }
239
+
240
+ existing.userIds = existing.userIds.filter((id) => id !== userId);
241
+ existing.count = existing.userIds.length;
242
+
243
+ // Remove reaction entry if no users left
244
+ reactions = reactions.filter((r) => r.count > 0);
245
+
246
+ const updated: FeedItem = { ...item, reactions };
247
+ this.items.set(feedId, updated);
248
+ return reactions;
249
+ }
250
+
251
+ async subscribe(input: SubscribeInput): Promise<RecordSubscription> {
252
+ const key = this.subscriptionKey(input.object, input.recordId, input.userId);
253
+ const existing = this.findSubscription(input.object, input.recordId, input.userId);
254
+
255
+ if (existing) {
256
+ // Update existing subscription
257
+ const updated: RecordSubscription = {
258
+ ...existing,
259
+ events: input.events ?? existing.events,
260
+ channels: input.channels ?? existing.channels,
261
+ active: true,
262
+ };
263
+ this.subscriptions.set(key, updated);
264
+ return updated;
265
+ }
266
+
267
+ const now = new Date().toISOString();
268
+ const subscription: RecordSubscription = {
269
+ object: input.object,
270
+ recordId: input.recordId,
271
+ userId: input.userId,
272
+ events: input.events ?? ['all'],
273
+ channels: input.channels ?? ['in_app'],
274
+ active: true,
275
+ createdAt: now,
276
+ };
277
+
278
+ this.subscriptions.set(key, subscription);
279
+ return subscription;
280
+ }
281
+
282
+ async unsubscribe(object: string, recordId: string, userId: string): Promise<boolean> {
283
+ const key = this.subscriptionKey(object, recordId, userId);
284
+ return this.subscriptions.delete(key);
285
+ }
286
+
287
+ async getSubscription(
288
+ object: string,
289
+ recordId: string,
290
+ userId: string,
291
+ ): Promise<RecordSubscription | null> {
292
+ return this.findSubscription(object, recordId, userId);
293
+ }
294
+
295
+ /**
296
+ * Get the total number of feed items stored.
297
+ */
298
+ getItemCount(): number {
299
+ return this.items.size;
300
+ }
301
+
302
+ /**
303
+ * Get the total number of subscriptions stored.
304
+ */
305
+ getSubscriptionCount(): number {
306
+ return this.subscriptions.size;
307
+ }
308
+
309
+ private subscriptionKey(object: string, recordId: string, userId: string): string {
310
+ return `${object}:${recordId}:${userId}`;
311
+ }
312
+
313
+ private findSubscription(
314
+ object: string,
315
+ recordId: string,
316
+ userId: string,
317
+ ): RecordSubscription | null {
318
+ const key = this.subscriptionKey(object, recordId, userId);
319
+ return this.subscriptions.get(key) ?? null;
320
+ }
321
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export { FeedServicePlugin } from './feed-service-plugin.js';
4
+ export type { FeedServicePluginOptions } from './feed-service-plugin.js';
5
+ export { InMemoryFeedAdapter } from './in-memory-feed-adapter.js';
6
+ export type { InMemoryFeedAdapterOptions } from './in-memory-feed-adapter.js';
7
+
8
+ // Feed Service Objects (metadata definitions)
9
+ export { FeedItem, FeedReaction, RecordSubscription } from './objects/index.js';
@@ -0,0 +1,162 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * Feed Item Object
7
+ *
8
+ * System object for storing feed/chatter items including comments,
9
+ * field changes, tasks, events, and system activities.
10
+ *
11
+ * Belongs to `service-feed` package per "protocol + service ownership" pattern.
12
+ */
13
+ export const FeedItem = ObjectSchema.create({
14
+ name: 'sys_feed_item',
15
+ label: 'Feed Item',
16
+ pluralLabel: 'Feed Items',
17
+ icon: 'message-square',
18
+ description: 'Unified activity timeline entries (comments, field changes, tasks, events)',
19
+ titleFormat: '{type}: {body}',
20
+ compactLayout: ['type', 'object', 'record_id', 'created_at'],
21
+
22
+ fields: {
23
+ id: Field.text({
24
+ label: 'Feed Item ID',
25
+ required: true,
26
+ readonly: true,
27
+ }),
28
+
29
+ type: Field.select({
30
+ label: 'Type',
31
+ required: true,
32
+ options: [
33
+ { label: 'Comment', value: 'comment' },
34
+ { label: 'Field Change', value: 'field_change' },
35
+ { label: 'Task', value: 'task' },
36
+ { label: 'Event', value: 'event' },
37
+ { label: 'Email', value: 'email' },
38
+ { label: 'Call', value: 'call' },
39
+ { label: 'Note', value: 'note' },
40
+ { label: 'File', value: 'file' },
41
+ { label: 'Record Create', value: 'record_create' },
42
+ { label: 'Record Delete', value: 'record_delete' },
43
+ { label: 'Approval', value: 'approval' },
44
+ { label: 'Sharing', value: 'sharing' },
45
+ { label: 'System', value: 'system' },
46
+ ],
47
+ }),
48
+
49
+ object: Field.text({
50
+ label: 'Object Name',
51
+ required: true,
52
+ searchable: true,
53
+ }),
54
+
55
+ record_id: Field.text({
56
+ label: 'Record ID',
57
+ required: true,
58
+ searchable: true,
59
+ }),
60
+
61
+ actor_type: Field.select({
62
+ label: 'Actor Type',
63
+ required: true,
64
+ options: [
65
+ { label: 'User', value: 'user' },
66
+ { label: 'System', value: 'system' },
67
+ { label: 'Service', value: 'service' },
68
+ { label: 'Automation', value: 'automation' },
69
+ ],
70
+ }),
71
+
72
+ actor_id: Field.text({
73
+ label: 'Actor ID',
74
+ required: true,
75
+ }),
76
+
77
+ actor_name: Field.text({
78
+ label: 'Actor Name',
79
+ }),
80
+
81
+ actor_avatar_url: Field.url({
82
+ label: 'Actor Avatar URL',
83
+ }),
84
+
85
+ body: Field.textarea({
86
+ label: 'Body',
87
+ description: 'Rich text body (Markdown supported)',
88
+ }),
89
+
90
+ mentions: Field.textarea({
91
+ label: 'Mentions',
92
+ description: 'Array of @mention objects (JSON)',
93
+ }),
94
+
95
+ changes: Field.textarea({
96
+ label: 'Field Changes',
97
+ description: 'Array of field change entries (JSON)',
98
+ }),
99
+
100
+ reactions: Field.textarea({
101
+ label: 'Reactions',
102
+ description: 'Array of emoji reaction objects (JSON)',
103
+ }),
104
+
105
+ parent_id: Field.text({
106
+ label: 'Parent Feed Item ID',
107
+ description: 'For threaded replies',
108
+ }),
109
+
110
+ reply_count: Field.number({
111
+ label: 'Reply Count',
112
+ defaultValue: 0,
113
+ }),
114
+
115
+ visibility: Field.select({
116
+ label: 'Visibility',
117
+ defaultValue: 'public',
118
+ options: [
119
+ { label: 'Public', value: 'public' },
120
+ { label: 'Internal', value: 'internal' },
121
+ { label: 'Private', value: 'private' },
122
+ ],
123
+ }),
124
+
125
+ is_edited: Field.boolean({
126
+ label: 'Is Edited',
127
+ defaultValue: false,
128
+ }),
129
+
130
+ edited_at: Field.datetime({
131
+ label: 'Edited At',
132
+ }),
133
+
134
+ created_at: Field.datetime({
135
+ label: 'Created At',
136
+ defaultValue: 'NOW()',
137
+ readonly: true,
138
+ }),
139
+
140
+ updated_at: Field.datetime({
141
+ label: 'Updated At',
142
+ defaultValue: 'NOW()',
143
+ readonly: true,
144
+ }),
145
+ },
146
+
147
+ indexes: [
148
+ { fields: ['object', 'record_id'], unique: false },
149
+ { fields: ['actor_id'], unique: false },
150
+ { fields: ['parent_id'], unique: false },
151
+ { fields: ['created_at'], unique: false },
152
+ ],
153
+
154
+ enable: {
155
+ trackHistory: false,
156
+ searchable: true,
157
+ apiEnabled: true,
158
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
159
+ trash: false,
160
+ mru: false,
161
+ },
162
+ });
@@ -0,0 +1,66 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * Feed Reaction Object
7
+ *
8
+ * System object for storing individual emoji reactions on feed items.
9
+ * Each row represents one user's reaction on one feed item.
10
+ *
11
+ * Belongs to `service-feed` package per "protocol + service ownership" pattern.
12
+ */
13
+ export const FeedReaction = ObjectSchema.create({
14
+ name: 'sys_feed_reaction',
15
+ label: 'Feed Reaction',
16
+ pluralLabel: 'Feed Reactions',
17
+ icon: 'smile',
18
+ description: 'Emoji reactions on feed items',
19
+ titleFormat: '{emoji} by {user_id}',
20
+ compactLayout: ['feed_item_id', 'emoji', 'user_id'],
21
+
22
+ fields: {
23
+ id: Field.text({
24
+ label: 'Reaction ID',
25
+ required: true,
26
+ readonly: true,
27
+ }),
28
+
29
+ feed_item_id: Field.text({
30
+ label: 'Feed Item ID',
31
+ required: true,
32
+ }),
33
+
34
+ emoji: Field.text({
35
+ label: 'Emoji',
36
+ required: true,
37
+ description: 'Emoji character or shortcode (e.g., "👍", ":thumbsup:")',
38
+ }),
39
+
40
+ user_id: Field.text({
41
+ label: 'User ID',
42
+ required: true,
43
+ }),
44
+
45
+ created_at: Field.datetime({
46
+ label: 'Created At',
47
+ defaultValue: 'NOW()',
48
+ readonly: true,
49
+ }),
50
+ },
51
+
52
+ indexes: [
53
+ { fields: ['feed_item_id', 'emoji', 'user_id'], unique: true },
54
+ { fields: ['feed_item_id'], unique: false },
55
+ { fields: ['user_id'], unique: false },
56
+ ],
57
+
58
+ enable: {
59
+ trackHistory: false,
60
+ searchable: false,
61
+ apiEnabled: true,
62
+ apiMethods: ['get', 'list', 'create', 'delete'],
63
+ trash: false,
64
+ mru: false,
65
+ },
66
+ });
@@ -0,0 +1,13 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Feed Service Objects
5
+ *
6
+ * ObjectQL-based object definitions for Feed/Chatter database schema.
7
+ * These objects define the persistent storage model for feed items,
8
+ * reactions, and record subscriptions.
9
+ */
10
+
11
+ export { FeedItem } from './feed-item.object.js';
12
+ export { FeedReaction } from './feed-reaction.object.js';
13
+ export { RecordSubscription } from './record-subscription.object.js';
@@ -0,0 +1,80 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * Record Subscription Object
7
+ *
8
+ * System object for storing record-level notification subscriptions.
9
+ * Enables Airtable-style bell icon for record change notifications.
10
+ *
11
+ * Belongs to `service-feed` package per "protocol + service ownership" pattern.
12
+ */
13
+ export const RecordSubscription = ObjectSchema.create({
14
+ name: 'sys_record_subscription',
15
+ label: 'Record Subscription',
16
+ pluralLabel: 'Record Subscriptions',
17
+ icon: 'bell',
18
+ description: 'Record-level notification subscriptions for feed events',
19
+ titleFormat: '{object}/{record_id} — {user_id}',
20
+ compactLayout: ['object', 'record_id', 'user_id', 'active'],
21
+
22
+ fields: {
23
+ id: Field.text({
24
+ label: 'Subscription ID',
25
+ required: true,
26
+ readonly: true,
27
+ }),
28
+
29
+ object: Field.text({
30
+ label: 'Object Name',
31
+ required: true,
32
+ }),
33
+
34
+ record_id: Field.text({
35
+ label: 'Record ID',
36
+ required: true,
37
+ }),
38
+
39
+ user_id: Field.text({
40
+ label: 'User ID',
41
+ required: true,
42
+ }),
43
+
44
+ events: Field.textarea({
45
+ label: 'Subscribed Events',
46
+ description: 'Array of event types: comment, mention, field_change, task, approval, all (JSON)',
47
+ }),
48
+
49
+ channels: Field.textarea({
50
+ label: 'Notification Channels',
51
+ description: 'Array of channels: in_app, email, push, slack (JSON)',
52
+ }),
53
+
54
+ active: Field.boolean({
55
+ label: 'Active',
56
+ defaultValue: true,
57
+ }),
58
+
59
+ created_at: Field.datetime({
60
+ label: 'Created At',
61
+ defaultValue: 'NOW()',
62
+ readonly: true,
63
+ }),
64
+ },
65
+
66
+ indexes: [
67
+ { fields: ['object', 'record_id', 'user_id'], unique: true },
68
+ { fields: ['user_id'], unique: false },
69
+ { fields: ['object', 'record_id'], unique: false },
70
+ ],
71
+
72
+ enable: {
73
+ trackHistory: false,
74
+ searchable: false,
75
+ apiEnabled: true,
76
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
77
+ trash: false,
78
+ mru: false,
79
+ },
80
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }