diff --git a/.changeset/config.json b/.changeset/config.json index f8d741691a..6a32845941 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -65,7 +65,6 @@ "@objectstack/service-cluster", "@objectstack/service-cluster-redis", "@objectstack/service-datasource", - "@objectstack/service-feed", "@objectstack/service-i18n", "@objectstack/service-job", "@objectstack/service-knowledge", diff --git a/docs/adr/0052-audit-is-not-the-activity-feed.md b/docs/adr/0052-audit-is-not-the-activity-feed.md index 81dbe5bb68..0405fd5f1f 100644 --- a/docs/adr/0052-audit-is-not-the-activity-feed.md +++ b/docs/adr/0052-audit-is-not-the-activity-feed.md @@ -161,39 +161,44 @@ boundary — which is only possible once mutable co-tenants (`sys_comment`) leav Audit **remains a default platform capability**: compliance is foundational, durable, and HA-safe. It just stops being the dumping ground. -## 5. Collaboration / activity: one backend, and the `ALWAYS_ON` question - -There are two implementations; we must converge to one. The recommended target -is **`service-feed`'s model** — a single unified, *typed* timeline -(`FeedItemType` already enumerates `comment | field_change | task | event | -email | call | note | …`), with reactions, mentions, threaded replies, and -record subscriptions — because it is the right long-term shape (a business event -stream, not a CRUD log). But it is **not shippable as-is**, and therefore **must -not be added to `ALWAYS_ON_CAPABILITIES` today**. Admission criteria -(`ALWAYS_ON` = foundational **and** durable/HA-safe **and** actually consumed): - -1. **Durable adapter.** `service-feed` currently ships an in-memory, - single-instance, non-durable adapter ("v1: single-instance only; data lost on - restart"). A DB-backed adapter is a precondition for default-on. -2. **REST surface mounted.** The nested `/api/v1/data/{object}/{recordId}/feed` - contract (`feed-api.zod.ts`) returns `404` — the route is unimplemented in - the rest server. Mount it. -3. **UI consumes the service.** The console **ChatterPanel** is hard-wired to - `sys_comment` / `sys_activity` / `sys_user`. Point it at `IFeedService`. -4. **Migration / aliasing.** `sys_comment` rows (DB-backed, in use) must be - migrated or aliased onto the canonical store so no comment is lost and - `thread_id = {object}:{recordId}` continues to resolve. - -Until 1–4 land, the **DB-backed `sys_comment` + `sys_activity` remain the -canonical, default-available collaboration surface** (they already work), and -the short-term, highest-ROI UI win is to render the `reactions` / `parent_id` -fields `sys_comment` **already declares** — no `feed` flag required. - -> Counter-option: make **`sys_comment`** the canonical model and retire -> `service-feed` into it (it already has `thread_id`, `parent_id`, `reply_count`, -> `mentions`, `reactions`). Cheaper, durable today, but keeps three separate -> objects instead of one unified typed feed. Decision deferred to the build spec; -> either way, **the terminal state is one backend, not two.** +## 5. Collaboration / activity: one backend — **DECIDED: `sys_comment`** + +There were two implementations; we converge to one. **Decision: `sys_comment` / +`sys_activity` is canonical; `@objectstack/service-feed` is retired.** + +The originally-recommended target was `service-feed`'s single unified *typed* +timeline. But weighing it against the implementation reality reversed that lean: + +| | `sys_comment` / `sys_activity` (chosen) | `service-feed` (retired) | +|---|---|---| +| Durability | ✅ DB-backed | ❌ in-memory only ("v1: single-instance; data lost on restart") | +| Default-loaded | ✅ (via audit slate) | ❌ opt-in capability | +| UI consumes it | ✅ ChatterPanel reads/writes it | ❌ never consumed (enabling `feed` was a verified no-op) | +| REST | ✅ generic data API | ❌ nested `/data/{obj}/{id}/feed` route unmounted (404) | +| threads/mentions/reactions | ✅ fields already declared (`parent_id`, `reply_count`, `mentions`, `reactions`) | ✅ (but unreachable) | + +Picking the durable, default, UI-wired system reaches "one backend" **now**, at +near-zero risk. `service-feed`'s only real edge — one unified *typed* stream — +is obtained on the chosen family by treating **`sys_activity` as the unified +typed timeline** (its `type` enum already carries the event kinds; extend it to +`email | call | event | note` as needed). The two remaining UI niceties +(reactions, threaded replies) are a render of fields `sys_comment` **already** +has — an objectui enhancement, not a backend change. + +Rejected alternative — invest in `service-feed`: building a DB adapter + mounting +the REST route + repointing ChatterPanel + migrating `sys_comment` rows is weeks +of cross-repo work to **duplicate a system that already works durably**. That is +the split-brain this ADR exists to end, not extend. + +> Superseded note (kept for history): an earlier draft deferred this to a build +> spec and leaned toward `service-feed`. Implementation reality (in-memory, +> UI-unused, REST-unmounted) decided it the other way. + +The terminal state is **one backend, not two** — now realized: `service-feed`'s +runtime (the package + the `feed` capability) is removed; `sys_comment` / +`sys_activity` stand alone. (The vestigial spec *contracts* — `feed.zod` / +`feed-api.zod` / `IFeedService` — are a separate type-surface cleanup, since they +are woven into `component.zod` / `protocol.zod` / objectql; tracked as follow-up.) ## 5b. Declarative activity — the platform generates it, apps don't code it diff --git a/packages/cli/package.json b/packages/cli/package.json index dedae6f680..ce31fa1074 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -78,7 +78,6 @@ "@objectstack/service-automation": "workspace:*", "@objectstack/service-cache": "workspace:*", "@objectstack/service-datasource": "workspace:*", - "@objectstack/service-feed": "workspace:*", "@objectstack/service-job": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-package": "workspace:*", diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index c7f0ee1fd2..99925509b4 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1524,11 +1524,11 @@ export default class Serve extends Command { export: 'RealtimeServicePlugin', nameMatch: ['service-realtime', 'RealtimeServicePlugin'], }, - feed: { - pkg: '@objectstack/service-feed', - export: 'FeedServicePlugin', - nameMatch: ['service-feed', 'FeedServicePlugin'], - }, + // `feed` removed (ADR-0052 §5): `sys_comment`/`sys_activity` (durable, + // default-loaded, UI-wired) is the canonical record collaboration + + // timeline backend. `@objectstack/service-feed` was an in-memory, + // non-durable, UI-unconsumed parallel implementation — retired to end + // the split-brain. The unified typed timeline lives on `sys_activity`. mcp: { pkg: '@objectstack/mcp', export: 'MCPServerPlugin', diff --git a/packages/services/service-feed/CHANGELOG.md b/packages/services/service-feed/CHANGELOG.md deleted file mode 100644 index 1b4b07083f..0000000000 --- a/packages/services/service-feed/CHANGELOG.md +++ /dev/null @@ -1,600 +0,0 @@ -# @objectstack/service-feed - -## 9.7.0 - -### Patch Changes - -- @objectstack/spec@9.7.0 -- @objectstack/core@9.7.0 - -## 9.6.0 - -### Patch Changes - -- Updated dependencies [d1e930a] -- Updated dependencies [71578f2] -- Updated dependencies [5e3a301] -- Updated dependencies [5db2742] - - @objectstack/spec@9.6.0 - - @objectstack/core@9.6.0 - -## 9.5.1 - -### Patch Changes - -- Updated dependencies [ee72aae] - - @objectstack/spec@9.5.1 - - @objectstack/core@9.5.1 - -## 9.5.0 - -### Minor Changes - -- f19caef: feat(P0-5): default-on memory caps for in-memory feed/realtime (single-instance GA) - - Launch-readiness P0-5 is resolved by formally scoping v1 to **single-instance** - (non-HA) and shipping memory backstops so the process-local adapters can't grow - until OOM: - - - `InMemoryFeedAdapter` now defaults `maxItems` to `DEFAULT_MAX_FEED_ITEMS` - (100k) instead of unbounded. `createFeedItem` throws loudly at the cap - (fail-loud beats a silent OOM kill). - - `InMemoryRealtimeAdapter` now defaults `maxSubscriptions` to - `DEFAULT_MAX_SUBSCRIPTIONS` (50k). - - Passing `0` is an explicit unbounded opt-out (tests / short-lived processes). - - Both plugin JSDocs now state the non-HA contract; HA (a Redis-backed realtime - adapter over the existing `RedisPubSub`, and a DB-backed feed adapter) is a - documented post-GA fast-follow. - - **Behaviour change:** a deployment that previously relied on unbounded in-memory - feed/realtime will now hit the cap and receive an error past the ceiling — set - `maxItems: 0` / `maxSubscriptions: 0` to restore the old behaviour, or raise the - number. - -### Patch Changes - -- Updated dependencies [d08551c] -- Updated dependencies [707aeed] -- Updated dependencies [7a103d4] -- Updated dependencies [4b01250] - - @objectstack/spec@9.5.0 - - @objectstack/core@9.5.0 - -## 9.4.0 - -### Patch Changes - -- Updated dependencies [060467a] -- Updated dependencies [0856476] -- Updated dependencies [b678d8c] -- Updated dependencies [b678d8c] -- Updated dependencies [b678d8c] - - @objectstack/spec@9.4.0 - - @objectstack/core@9.4.0 - -## 9.3.0 - -### Patch Changes - -- Updated dependencies [1ada658] -- Updated dependencies [3219191] -- Updated dependencies [290f631] -- Updated dependencies [50b7b47] -- Updated dependencies [f15d6f6] -- Updated dependencies [f8684ea] -- Updated dependencies [b4765be] - - @objectstack/spec@9.3.0 - - @objectstack/core@9.3.0 - -## 9.2.0 - -### Patch Changes - -- Updated dependencies [2f57b75] -- Updated dependencies [2f57b75] - - @objectstack/spec@9.2.0 - - @objectstack/core@9.2.0 - -## 9.1.0 - -### Patch Changes - -- Updated dependencies [b9062c9] - - @objectstack/spec@9.1.0 - - @objectstack/core@9.1.0 - -## 9.0.1 - -### Patch Changes - -- Updated dependencies [1817845] - - @objectstack/spec@9.0.1 - - @objectstack/core@9.0.1 - -## 9.0.0 - -### Patch Changes - -- Updated dependencies [4c3f693] -- Updated dependencies [0bf39f1] -- Updated dependencies [f533f42] -- Updated dependencies [1c83ee8] - - @objectstack/spec@9.0.0 - - @objectstack/core@9.0.0 - -## 8.0.1 - -### Patch Changes - -- @objectstack/spec@8.0.1 -- @objectstack/core@8.0.1 - -## 8.0.0 - -### Patch Changes - -- Updated dependencies [a46c017] -- Updated dependencies [b990b89] -- Updated dependencies [99111ec] -- Updated dependencies [d5a8161] -- Updated dependencies [5cf1f1b] -- Updated dependencies [9ef89d4] -- Updated dependencies [3306d2f] -- Updated dependencies [c262301] -- Updated dependencies [bc44195] -- Updated dependencies [9e2e229] - - @objectstack/spec@8.0.0 - - @objectstack/core@8.0.0 - -## 7.9.0 - -### Patch Changes - -- @objectstack/spec@7.9.0 -- @objectstack/core@7.9.0 - -## 7.8.0 - -### Patch Changes - -- Updated dependencies [06f2bbb] -- Updated dependencies [36719db] -- Updated dependencies [424ab26] - - @objectstack/spec@7.8.0 - - @objectstack/core@7.8.0 - -## 7.7.0 - -### Patch Changes - -- Updated dependencies [b391955] -- Updated dependencies [f06b64e] -- Updated dependencies [023bf93] - - @objectstack/spec@7.7.0 - - @objectstack/core@7.7.0 - -## 7.6.0 - -### Patch Changes - -- Updated dependencies [955d4c8] -- Updated dependencies [c4a4cbd] -- Updated dependencies [b046ec2] -- Updated dependencies [2170ad9] -- Updated dependencies [02d6359] -- Updated dependencies [7648242] -- Updated dependencies [8fa1e7f] -- Updated dependencies [55866f5] -- Updated dependencies [60f9c45] - - @objectstack/spec@7.6.0 - - @objectstack/core@7.6.0 - -## 7.5.0 - -### Patch Changes - -- @objectstack/spec@7.5.0 -- @objectstack/core@7.5.0 - -## 7.4.1 - -### Patch Changes - -- @objectstack/spec@7.4.1 -- @objectstack/core@7.4.1 - -## 7.4.0 - -### Patch Changes - -- Updated dependencies [23c7107] -- Updated dependencies [c72daad] -- Updated dependencies [f115182] -- Updated dependencies [2faf9f2] -- Updated dependencies [2faf9f2] -- Updated dependencies [2faf9f2] -- Updated dependencies [58b450b] -- Updated dependencies [82eb6cf] -- Updated dependencies [13d8653] -- Updated dependencies [ff3d006] -- Updated dependencies [5e831de] - - @objectstack/spec@7.4.0 - - @objectstack/core@7.4.0 - -## 7.3.0 - -### Patch Changes - -- Updated dependencies [5e7c554] - - @objectstack/spec@7.3.0 - - @objectstack/core@7.3.0 - -## 7.2.1 - -### Patch Changes - -- @objectstack/spec@7.2.1 -- @objectstack/core@7.2.1 - -## 7.2.0 - -### Patch Changes - -- @objectstack/spec@7.2.0 -- @objectstack/core@7.2.0 - -## 7.1.0 - -### Patch Changes - -- Updated dependencies [47a92f4] - - @objectstack/spec@7.1.0 - - @objectstack/core@7.1.0 - -## 7.0.0 - -### Patch Changes - -- Updated dependencies [74470ad] -- Updated dependencies [d29617e] -- Updated dependencies [dc72172] - - @objectstack/spec@7.0.0 - - @objectstack/core@7.0.0 - -## 6.9.0 - -### Patch Changes - -- @objectstack/spec@6.9.0 -- @objectstack/core@6.9.0 - -## 6.8.1 - -### Patch Changes - -- @objectstack/spec@6.8.1 -- @objectstack/core@6.8.1 - -## 6.8.0 - -### Patch Changes - -- Updated dependencies [6e88f77] -- Updated dependencies [c8b9f57] - - @objectstack/spec@6.8.0 - - @objectstack/core@6.8.0 - -## 6.7.1 - -### Patch Changes - -- @objectstack/spec@6.7.1 -- @objectstack/core@6.7.1 - -## 6.7.0 - -### Patch Changes - -- Updated dependencies [430067b] -- Updated dependencies [4f9e9d4] - - @objectstack/spec@6.7.0 - - @objectstack/core@6.7.0 - -## 6.6.0 - -### Patch Changes - -- Updated dependencies [a49cfc2] - - @objectstack/spec@6.6.0 - - @objectstack/core@6.6.0 - -## 6.5.1 - -### Patch Changes - -- @objectstack/spec@6.5.1 -- @objectstack/core@6.5.1 - -## 6.5.0 - -### Patch Changes - -- @objectstack/spec@6.5.0 -- @objectstack/core@6.5.0 - -## 6.4.0 - -### Patch Changes - -- Updated dependencies [f8651cc] -- Updated dependencies [f8651cc] -- Updated dependencies [0bf6f9a] - - @objectstack/spec@6.4.0 - - @objectstack/core@6.4.0 - -## 6.3.0 - -### Patch Changes - -- @objectstack/spec@6.3.0 -- @objectstack/core@6.3.0 - -## 6.2.0 - -### Patch Changes - -- Updated dependencies [b4c74a9] - - @objectstack/spec@6.2.0 - - @objectstack/core@6.2.0 - -## 6.1.1 - -### Patch Changes - -- @objectstack/spec@6.1.1 -- @objectstack/core@6.1.1 - -## 6.1.0 - -### Patch Changes - -- Updated dependencies [93c0589] - - @objectstack/spec@6.1.0 - - @objectstack/core@6.1.0 - -## 6.0.0 - -### Patch Changes - -- Updated dependencies [629a716] -- Updated dependencies [dbc4f7d] -- Updated dependencies [944f187] - - @objectstack/spec@6.0.0 - - @objectstack/core@6.0.0 - -## 5.2.0 - -### Patch Changes - -- Updated dependencies [bab2b20] -- Updated dependencies [fa011d8] -- Updated dependencies [b806f58] - - @objectstack/spec@5.2.0 - - @objectstack/core@5.2.0 - -## 5.1.0 - -### Patch Changes - -- Updated dependencies [75f4ee6] -- Updated dependencies [823d559] - - @objectstack/spec@5.1.0 - - @objectstack/core@5.1.0 - -## 5.0.0 - -### Patch Changes - -- Updated dependencies [2f9073a] - - @objectstack/spec@5.0.0 - - @objectstack/core@5.0.0 - -## 4.2.0 - -### Patch Changes - -- Updated dependencies [2869891] - - @objectstack/spec@4.2.0 - - @objectstack/core@4.2.0 - -## 4.1.1 - -### Patch Changes - -- @objectstack/spec@4.1.1 -- @objectstack/core@4.1.1 - -## 4.1.0 - -### Patch Changes - -- Updated dependencies [2108c30] -- Updated dependencies [23db640] - - @objectstack/spec@4.1.0 - - @objectstack/core@4.1.0 - -## 4.0.5 - -### Patch Changes - -- 15e0df6: chore: unify all package versions to a single patch release -- Updated dependencies [15e0df6] - - @objectstack/spec@4.0.5 - - @objectstack/core@4.0.5 - -## 4.0.4 - -### Patch Changes - -- Updated dependencies [326b66b] - - @objectstack/spec@4.0.4 - - @objectstack/core@4.0.4 - -## 4.0.3 - -### Patch Changes - -- @objectstack/spec@4.0.3 -- @objectstack/core@4.0.3 - -## 4.0.2 - -### Patch Changes - -- Updated dependencies [5f659e9] - - @objectstack/spec@4.0.2 - - @objectstack/core@4.0.2 - -## 4.0.0 - -### Patch Changes - -- Updated dependencies [f08ffc3] -- Updated dependencies [e0b0a78] - - @objectstack/spec@4.0.0 - - @objectstack/core@4.0.0 - -## 3.3.1 - -### Patch Changes - -- @objectstack/spec@3.3.1 -- @objectstack/core@3.3.1 - -## 3.3.0 - -### Patch Changes - -- @objectstack/spec@3.3.0 -- @objectstack/core@3.3.0 - -## 3.2.9 - -### Patch Changes - -- @objectstack/spec@3.2.9 -- @objectstack/core@3.2.9 - -## 3.2.8 - -### Patch Changes - -- @objectstack/spec@3.2.8 -- @objectstack/core@3.2.8 - -## 3.2.7 - -### Patch Changes - -- @objectstack/spec@3.2.7 -- @objectstack/core@3.2.7 - -## 3.2.6 - -### Patch Changes - -- @objectstack/spec@3.2.6 -- @objectstack/core@3.2.6 - -## 3.2.5 - -### Patch Changes - -- @objectstack/spec@3.2.5 -- @objectstack/core@3.2.5 - -## 3.2.4 - -### Patch Changes - -- @objectstack/spec@3.2.4 -- @objectstack/core@3.2.4 - -## 3.2.3 - -### Patch Changes - -- @objectstack/spec@3.2.3 -- @objectstack/core@3.2.3 - -## 3.2.2 - -### Patch Changes - -- Updated dependencies [46defbb] - - @objectstack/spec@3.2.2 - - @objectstack/core@3.2.2 - -## 3.2.1 - -### Patch Changes - -- Updated dependencies [850b546] - - @objectstack/spec@3.2.1 - - @objectstack/core@3.2.1 - -## 3.2.0 - -### Patch Changes - -- Updated dependencies [5901c29] - - @objectstack/spec@3.2.0 - - @objectstack/core@3.2.0 - -## 3.1.1 - -### Patch Changes - -- Updated dependencies [953d667] - - @objectstack/spec@3.1.1 - - @objectstack/core@3.1.1 - -## 3.1.0 - -### Patch Changes - -- Updated dependencies [0088830] - - @objectstack/spec@3.1.0 - - @objectstack/core@3.1.0 - -## 3.0.11 - -### Patch Changes - -- Updated dependencies [92d9d99] - - @objectstack/spec@3.0.11 - - @objectstack/core@3.0.11 - -## 3.0.10 - -### Patch Changes - -- Updated dependencies [d1e5d31] - - @objectstack/spec@3.0.10 - - @objectstack/core@3.0.10 - -## 3.0.9 - -### Patch Changes - -- Updated dependencies [15e0df6] - - @objectstack/spec@3.0.9 - - @objectstack/core@3.0.9 - -## 3.0.8 - -### Patch Changes - -- Updated dependencies [5a968a2] - - @objectstack/spec@3.0.8 - - @objectstack/core@3.0.8 diff --git a/packages/services/service-feed/LICENSE b/packages/services/service-feed/LICENSE deleted file mode 100644 index 93fba8d887..0000000000 --- a/packages/services/service-feed/LICENSE +++ /dev/null @@ -1,93 +0,0 @@ -License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. -"Business Source License" is a trademark of MariaDB Corporation Ab. - -Parameters - -Licensor: ObjectStack AI LLC -Licensed Work: ObjectStack Runtime: the BSL-licensed packages - of the ObjectStack monorepo as listed in LICENSING.md. - Copyright (c) 2026 ObjectStack AI LLC. -Additional Use Grant: You may make production use of the Licensed Work, provided - Your use does not include offering the Licensed Work to third - parties on a hosted or embedded basis in order to compete with - ObjectStack AI LLC's paid version(s) of the Licensed Work. For purposes - of this license: - - A "competitive offering" is a Product that is offered to third - parties on a paid basis, including through paid support - arrangements, that significantly overlaps with the capabilities - of ObjectStack AI LLC's paid version(s) of the Licensed Work. If Your - Product is not a competitive offering when You first make it - generally available, it will not become a competitive offering - later due to ObjectStack AI LLC releasing a new version of the Licensed - Work with additional capabilities. In addition, Products that - are not provided on a paid basis are not competitive. - - "Product" means software that is offered to end users to manage - in their own environments or offered as a service on a hosted - basis. - - "Embedded" means including the source code or executable code - from the Licensed Work in a competitive offering. "Embedded" - also means packaging the competitive offering in such a way - that the Licensed Work must be accessed or downloaded for the - competitive offering to operate. - - Hosting or using the Licensed Work(s) for internal purposes - within an organization is not considered a competitive - offering. ObjectStack AI LLC considers your organization to include all - of your affiliates under common control. - - For binding interpretive guidance on using ObjectStack AI LLC products - under the Business Source License, please visit our FAQ. - (see LICENSING.md in this repository) -Change Date: Four years from the date the Licensed Work is published. -Change License: Apache License, Version 2.0 - -For information about alternative licensing arrangements for the Licensed Work, -please contact licensing@objectstack.dev. - -Notice - -Business Source License 1.1 - -Terms - -The Licensor hereby grants you the right to copy, modify, create derivative -works, redistribute, and make non-production use of the Licensed Work. The -Licensor may make an Additional Use Grant, above, permitting limited production use. - -Effective on the Change Date, or the fourth anniversary of the first publicly -available distribution of a specific version of the Licensed Work under this -License, whichever comes first, the Licensor hereby grants you rights under -the terms of the Change License, and the rights granted in the paragraph -above terminate. - -If your use of the Licensed Work does not comply with the requirements -currently in effect as described in this License, you must purchase a -commercial license from the Licensor, its affiliated entities, or authorized -resellers, or you must refrain from using the Licensed Work. - -All copies of the original and modified Licensed Work, and derivative works -of the Licensed Work, are subject to this License. This License applies -separately for each version of the Licensed Work and the Change Date may vary -for each version of the Licensed Work released by Licensor. - -You must conspicuously display this License on each original or modified copy -of the Licensed Work. If you receive the Licensed Work in original or -modified form from a third party, the terms and conditions set forth in this -License apply to your use of that work. - -Any use of the Licensed Work in violation of this License will automatically -terminate your rights under this License for the current and all other -versions of the Licensed Work. - -This License does not grant you any right in any trademark or logo of -Licensor or its affiliates (provided that you may use a trademark or logo of -Licensor as expressly required by this License). - -TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON -AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, -EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. diff --git a/packages/services/service-feed/README.md b/packages/services/service-feed/README.md deleted file mode 100644 index 015628e3c9..0000000000 --- a/packages/services/service-feed/README.md +++ /dev/null @@ -1,378 +0,0 @@ -# @objectstack/service-feed - -Feed/Chatter Service for ObjectStack — implements `IFeedService` with in-memory adapter for comments, reactions, field changes, and record subscriptions. - -## Features - -- **Activity Feed**: Track all record changes and user activities -- **Comments & Mentions**: Add comments with @mentions support -- **Reactions**: Like, upvote, or react to records and comments -- **Field Change Tracking**: Automatic history of field value changes -- **Subscriptions**: Subscribe to records for notifications -- **Rich Content**: Support for markdown, attachments, and embeds -- **Real-time Updates**: Integrates with `@objectstack/service-realtime` for live updates - -## Installation - -```bash -pnpm add @objectstack/service-feed -``` - -## Basic Usage - -```typescript -import { defineStack } from '@objectstack/spec'; -import { ServiceFeed } from '@objectstack/service-feed'; - -const stack = defineStack({ - services: [ - ServiceFeed.configure({ - enableFieldTracking: true, - enableMentions: true, - }), - ], -}); -``` - -## Configuration - -```typescript -interface FeedServiceConfig { - /** Enable automatic field change tracking */ - enableFieldTracking?: boolean; - - /** Enable @mention support in comments */ - enableMentions?: boolean; - - /** Enable reactions (likes, upvotes, etc.) */ - enableReactions?: boolean; - - /** Maximum feed items per page */ - pageSize?: number; -} -``` - -## Service API - -```typescript -// Get feed service -const feed = kernel.getService('feed'); -``` - -### Comments - -```typescript -// Add a comment to a record -await feed.addComment({ - object: 'opportunity', - recordId: '123', - userId: 'user:456', - body: 'Great progress on this deal! @john can you follow up?', -}); - -// Get comments for a record -const comments = await feed.getComments({ - object: 'opportunity', - recordId: '123', - limit: 20, -}); - -// Update a comment -await feed.updateComment({ - commentId: 'comment:789', - body: 'Updated comment text', -}); - -// Delete a comment -await feed.deleteComment('comment:789'); -``` - -### Reactions - -```typescript -// Add a reaction -await feed.addReaction({ - targetType: 'record', // or 'comment' - targetId: '123', - userId: 'user:456', - type: 'like', // 'like', 'love', 'upvote', 'celebrate' -}); - -// Remove a reaction -await feed.removeReaction({ - targetType: 'record', - targetId: '123', - userId: 'user:456', - type: 'like', -}); - -// Get reactions for a record -const reactions = await feed.getReactions({ - targetType: 'record', - targetId: '123', -}); -// Returns: { like: 12, love: 5, upvote: 8 } -``` - -### Activity Feed - -```typescript -// Get feed for a specific record -const recordFeed = await feed.getFeed({ - object: 'account', - recordId: '123', - types: ['comment', 'field_change', 'record_created'], - limit: 50, -}); - -// Get user's personalized feed (records they follow) -const userFeed = await feed.getUserFeed({ - userId: 'user:456', - limit: 100, -}); - -// Example feed item: -// { -// id: 'feed:abc', -// type: 'field_change', -// object: 'opportunity', -// recordId: '123', -// userId: 'user:456', -// timestamp: '2024-01-15T10:30:00Z', -// data: { -// field: 'stage', -// oldValue: 'prospecting', -// newValue: 'proposal' -// } -// } -``` - -### Subscriptions - -```typescript -// Subscribe to a record -await feed.subscribe({ - object: 'opportunity', - recordId: '123', - userId: 'user:456', -}); - -// Unsubscribe from a record -await feed.unsubscribe({ - object: 'opportunity', - recordId: '123', - userId: 'user:456', -}); - -// Check if user is subscribed -const isSubscribed = await feed.isSubscribed({ - object: 'opportunity', - recordId: '123', - userId: 'user:456', -}); - -// Get all subscriptions for a user -const subscriptions = await feed.getUserSubscriptions('user:456'); -``` - -### Field Change Tracking - -Field changes are automatically tracked when `enableFieldTracking` is enabled: - -```typescript -// Automatically creates feed items like: -// { -// type: 'field_change', -// object: 'opportunity', -// recordId: '123', -// userId: 'user:456', -// data: { -// field: 'amount', -// oldValue: 50000, -// newValue: 75000 -// } -// } -``` - -Customize which fields to track: - -```typescript -ServiceFeed.configure({ - enableFieldTracking: true, - trackedObjects: { - opportunity: ['stage', 'amount', 'close_date'], - account: ['status', 'industry'], - }, -}); -``` - -## Advanced Features - -### Mentions & Notifications - -```typescript -// Parse mentions from comment body -const mentions = feed.parseMentions('Hey @john and @sarah, check this out!'); -// Returns: ['john', 'sarah'] - -// Get mentions for a user -const userMentions = await feed.getMentions('user:456', { - unreadOnly: true, -}); - -// Mark mention as read -await feed.markMentionRead({ - mentionId: 'mention:abc', - userId: 'user:456', -}); -``` - -### Rich Content - -```typescript -await feed.addComment({ - object: 'opportunity', - recordId: '123', - userId: 'user:456', - body: '## Great News!\n\nWe closed the deal at **$100k**!', - format: 'markdown', - attachments: [ - { - type: 'file', - url: 'https://example.com/contract.pdf', - name: 'contract.pdf', - size: 1024000, - }, - ], -}); -``` - -### Filtering & Search - -```typescript -// Get feed with filters -const feed = await feed.getFeed({ - object: 'opportunity', - recordId: '123', - types: ['comment'], // Only comments - userId: 'user:456', // Only from specific user - since: '2024-01-01T00:00:00Z', - until: '2024-01-31T23:59:59Z', -}); - -// Search comments -const results = await feed.searchComments({ - query: 'follow up', - object: 'opportunity', - recordId: '123', -}); -``` - -## Integration with Realtime Service - -```typescript -// Subscribe to real-time feed updates -const realtime = kernel.getService('realtime'); - -realtime.subscribe(`feed:opportunity:123`, (event) => { - if (event.type === 'comment_added') { - console.log('New comment:', event.data); - } else if (event.type === 'field_changed') { - console.log('Field changed:', event.data); - } -}); -``` - -## REST API Endpoints - -``` -POST /api/v1/feed/comments # Add comment -GET /api/v1/feed/comments/:object/:recordId # Get comments -PATCH /api/v1/feed/comments/:id # Update comment -DELETE /api/v1/feed/comments/:id # Delete comment - -POST /api/v1/feed/reactions # Add reaction -DELETE /api/v1/feed/reactions # Remove reaction -GET /api/v1/feed/reactions/:type/:id # Get reactions - -GET /api/v1/feed/:object/:recordId # Get record feed -GET /api/v1/feed/user/:userId # Get user feed - -POST /api/v1/feed/subscriptions # Subscribe -DELETE /api/v1/feed/subscriptions # Unsubscribe -GET /api/v1/feed/subscriptions/:userId # Get subscriptions -``` - -## UI Integration - -### React Hook Example - -```typescript -import { useFeed } from '@objectstack/client-react'; - -function OpportunityFeed({ recordId }: { recordId: string }) { - const { feed, addComment, loading } = useFeed({ - object: 'opportunity', - recordId, - }); - - return ( -
- {feed.map((item) => ( - - ))} - -
- ); -} -``` - -## Best Practices - -1. **Enable Selective Tracking**: Track only important fields to reduce noise -2. **Use Pagination**: Always paginate feed queries to avoid performance issues -3. **Subscribe Sparingly**: Don't auto-subscribe users to too many records -4. **Moderate Content**: Implement moderation for user-generated comments -5. **Archive Old Data**: Periodically archive old feed items -6. **Index Efficiently**: Ensure database indexes on object/recordId/timestamp - -## Performance Considerations - -- **In-Memory Adapter**: Current implementation is in-memory only (future: database persistence) -- **Pagination**: Always use pagination for feed queries -- **Filtering**: Filter by type and date range to reduce result set -- **Caching**: Cache recent feed items for frequently accessed records - -## Contract Implementation - -Implements `IFeedService` from `@objectstack/spec/contracts`: - -```typescript -interface IFeedService { - addComment(options: AddCommentOptions): Promise; - getComments(options: GetCommentsOptions): Promise; - updateComment(options: UpdateCommentOptions): Promise; - deleteComment(commentId: string): Promise; - - addReaction(options: AddReactionOptions): Promise; - removeReaction(options: RemoveReactionOptions): Promise; - getReactions(options: GetReactionsOptions): Promise; - - getFeed(options: GetFeedOptions): Promise; - getUserFeed(options: GetUserFeedOptions): Promise; - - subscribe(options: SubscribeOptions): Promise; - unsubscribe(options: UnsubscribeOptions): Promise; - isSubscribed(options: IsSubscribedOptions): Promise; -} -``` - -## License - -Apache-2.0. See [LICENSING.md](../../../LICENSING.md). - -## See Also - -- [@objectstack/service-realtime](../service-realtime/) -- [@objectstack/spec/contracts](../../spec/src/contracts/) -- [Activity Feed Guide](/content/docs/guides/feed/) diff --git a/packages/services/service-feed/package.json b/packages/services/service-feed/package.json deleted file mode 100644 index 8617353874..0000000000 --- a/packages/services/service-feed/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@objectstack/service-feed", - "version": "9.7.0", - "license": "Apache-2.0", - "description": "Feed/Chatter Service for ObjectStack — implements IFeedService with in-memory adapter for comments, reactions, field changes, and record subscriptions", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "scripts": { - "build": "tsup --config ../../../tsup.config.ts", - "test": "vitest run" - }, - "dependencies": { - "@objectstack/core": "workspace:*", - "@objectstack/spec": "workspace:*" - }, - "devDependencies": { - "@types/node": "^25.9.3", - "typescript": "^6.0.3", - "vitest": "^4.1.9" - }, - "keywords": [ - "objectstack", - "service", - "feed", - "activity", - "chatter" - ], - "author": "ObjectStack", - "repository": { - "type": "git", - "url": "https://github.com/objectstack-ai/framework.git", - "directory": "packages/services/service-feed" - }, - "homepage": "https://objectstack.ai/docs", - "bugs": "https://github.com/objectstack-ai/framework/issues", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist", - "README.md" - ], - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/services/service-feed/src/feed-service-plugin.ts b/packages/services/service-feed/src/feed-service-plugin.ts deleted file mode 100644 index 1ee2e0b0ee..0000000000 --- a/packages/services/service-feed/src/feed-service-plugin.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Plugin, PluginContext } from '@objectstack/core'; -import { InMemoryFeedAdapter } from './in-memory-feed-adapter.js'; -import type { InMemoryFeedAdapterOptions } from './in-memory-feed-adapter.js'; -import { FeedItem, FeedReaction, RecordSubscription } from './objects/index.js'; - -/** - * Configuration options for the FeedServicePlugin. - */ -export interface FeedServicePluginOptions { - /** Feed adapter type (default: 'memory') */ - adapter?: 'memory'; - /** Options for the in-memory adapter */ - memory?: InMemoryFeedAdapterOptions; -} - -/** - * FeedServicePlugin — Production IFeedService implementation. - * - * Registers a Feed/Chatter service with the kernel during the init phase. - * Currently supports in-memory storage for single-process environments. - * - * **v1 deployment contract (launch-readiness.md P0-5): single-instance only.** - * The in-memory adapter is process-local and non-durable — feed data is lost on - * restart and is not shared across nodes. A default `maxItems` safety cap is - * enforced (see {@link InMemoryFeedAdapter}) so the store can't grow until OOM. - * HA (a DB-backed feed adapter) is a post-GA fast-follow. - * - * @example - * ```ts - * import { ObjectKernel } from '@objectstack/core'; - * import { FeedServicePlugin } from '@objectstack/service-feed'; - * - * const kernel = new ObjectKernel(); - * kernel.use(new FeedServicePlugin()); - * await kernel.bootstrap(); - * - * const feed = kernel.getService('feed'); - * const item = await feed.createFeedItem({ - * object: 'account', - * recordId: 'rec_123', - * type: 'comment', - * actor: { type: 'user', id: 'user_1', name: 'Alice' }, - * body: 'Great progress!', - * }); - * ``` - */ -export class FeedServicePlugin implements Plugin { - name = 'com.objectstack.service.feed'; - version = '1.0.0'; - type = 'standard'; - dependencies = ['com.objectstack.engine.objectql']; - - private readonly options: FeedServicePluginOptions; - - constructor(options: FeedServicePluginOptions = {}) { - this.options = { adapter: 'memory', ...options }; - } - - async init(ctx: PluginContext): Promise { - const feed = new InMemoryFeedAdapter(this.options.memory); - ctx.registerService('feed', feed); - - // Register feed system objects via the manifest service. - ctx.getService<{ register(m: any): void }>('manifest').register({ - id: 'com.objectstack.service.feed', - name: 'Feed Service', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [FeedItem, FeedReaction, RecordSubscription], - }); - - ctx.logger.info('FeedServicePlugin: registered in-memory feed adapter'); - } -} diff --git a/packages/services/service-feed/src/in-memory-feed-adapter.test.ts b/packages/services/service-feed/src/in-memory-feed-adapter.test.ts deleted file mode 100644 index ad9746b1ce..0000000000 --- a/packages/services/service-feed/src/in-memory-feed-adapter.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { InMemoryFeedAdapter, DEFAULT_MAX_FEED_ITEMS } from './in-memory-feed-adapter'; -import type { IFeedService, CreateFeedItemInput } from '@objectstack/spec/contracts'; - -/** Helper to create a standard comment input. */ -function commentInput(overrides: Partial = {}): CreateFeedItemInput { - return { - object: 'account', - recordId: 'rec_123', - type: 'comment', - actor: { type: 'user', id: 'user_1', name: 'Alice' }, - body: 'Hello world', - ...overrides, - }; -} - -describe('InMemoryFeedAdapter', () => { - // ========================================== - // Contract compliance - // ========================================== - - it('should implement IFeedService contract', () => { - const feed: IFeedService = new InMemoryFeedAdapter(); - expect(typeof feed.listFeed).toBe('function'); - expect(typeof feed.createFeedItem).toBe('function'); - expect(typeof feed.updateFeedItem).toBe('function'); - expect(typeof feed.deleteFeedItem).toBe('function'); - expect(typeof feed.getFeedItem).toBe('function'); - expect(typeof feed.addReaction).toBe('function'); - expect(typeof feed.removeReaction).toBe('function'); - expect(typeof feed.subscribe).toBe('function'); - expect(typeof feed.unsubscribe).toBe('function'); - expect(typeof feed.getSubscription).toBe('function'); - }); - - // ========================================== - // Feed CRUD - // ========================================== - - it('should start with zero items', () => { - const feed = new InMemoryFeedAdapter(); - expect(feed.getItemCount()).toBe(0); - }); - - it('should create a feed item and return it', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - expect(item.id).toBeDefined(); - expect(item.type).toBe('comment'); - expect(item.object).toBe('account'); - expect(item.recordId).toBe('rec_123'); - expect(item.actor.id).toBe('user_1'); - expect(item.body).toBe('Hello world'); - expect(item.visibility).toBe('public'); - expect(item.replyCount).toBe(0); - expect(item.isEdited).toBe(false); - expect(item.createdAt).toBeDefined(); - expect(feed.getItemCount()).toBe(1); - }); - - it('should get a feed item by ID', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - const fetched = await feed.getFeedItem(item.id); - expect(fetched).toEqual(item); - }); - - it('should return null for unknown feed item ID', async () => { - const feed = new InMemoryFeedAdapter(); - const result = await feed.getFeedItem('nonexistent'); - expect(result).toBeNull(); - }); - - it('should update a feed item body and mark as edited', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - const updated = await feed.updateFeedItem(item.id, { body: 'Updated text' }); - expect(updated.body).toBe('Updated text'); - expect(updated.isEdited).toBe(true); - expect(updated.editedAt).toBeDefined(); - expect(updated.updatedAt).toBeDefined(); - }); - - it('should update feed item visibility', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - const updated = await feed.updateFeedItem(item.id, { visibility: 'internal' }); - expect(updated.visibility).toBe('internal'); - }); - - it('should throw when updating a non-existent feed item', async () => { - const feed = new InMemoryFeedAdapter(); - await expect(feed.updateFeedItem('nonexistent', { body: 'x' })) - .rejects.toThrow(/Feed item not found/); - }); - - it('should delete a feed item', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.deleteFeedItem(item.id); - expect(feed.getItemCount()).toBe(0); - expect(await feed.getFeedItem(item.id)).toBeNull(); - }); - - it('should throw when deleting a non-existent feed item', async () => { - const feed = new InMemoryFeedAdapter(); - await expect(feed.deleteFeedItem('nonexistent')) - .rejects.toThrow(/Feed item not found/); - }); - - it('should enforce maxItems limit', async () => { - const feed = new InMemoryFeedAdapter({ maxItems: 2 }); - - await feed.createFeedItem(commentInput({ body: 'first' })); - await feed.createFeedItem(commentInput({ body: 'second' })); - - await expect(feed.createFeedItem(commentInput({ body: 'third' }))) - .rejects.toThrow(/Maximum feed item limit reached/); - }); - - it('enforces a default maxItems cap when none is configured (P0-5 backstop)', async () => { - // The cap is default-on so a long-running single-instance feed can't grow - // until OOM. Adapter constructed with no options uses the default cap. - const feed = new InMemoryFeedAdapter(); - expect(DEFAULT_MAX_FEED_ITEMS).toBeGreaterThan(0); - expect(Number.isFinite(DEFAULT_MAX_FEED_ITEMS)).toBe(true); - expect((feed as unknown as { maxItems: number }).maxItems).toBe(DEFAULT_MAX_FEED_ITEMS); - }); - - it('treats maxItems: 0 as an explicit unbounded opt-out', async () => { - const feed = new InMemoryFeedAdapter({ maxItems: 0 }); - expect((feed as unknown as { maxItems: number }).maxItems).toBe(0); - for (let i = 0; i < 5; i++) { - await feed.createFeedItem(commentInput({ body: `c${i}` })); - } - expect(feed.getItemCount()).toBe(5); - }); - - // ========================================== - // Feed Listing & Filtering - // ========================================== - - it('should list feed items for a record in reverse chronological order', async () => { - const feed = new InMemoryFeedAdapter(); - await feed.createFeedItem(commentInput({ body: 'first' })); - await feed.createFeedItem(commentInput({ body: 'second' })); - await feed.createFeedItem(commentInput({ body: 'third' })); - - const result = await feed.listFeed({ object: 'account', recordId: 'rec_123' }); - expect(result.items).toHaveLength(3); - expect(result.total).toBe(3); - expect(result.hasMore).toBe(false); - // Reverse chronological: third, second, first - expect(result.items[0].body).toBe('third'); - expect(result.items[2].body).toBe('first'); - }); - - it('should not return items from other records', async () => { - const feed = new InMemoryFeedAdapter(); - await feed.createFeedItem(commentInput({ recordId: 'rec_A' })); - await feed.createFeedItem(commentInput({ recordId: 'rec_B' })); - - const result = await feed.listFeed({ object: 'account', recordId: 'rec_A' }); - expect(result.items).toHaveLength(1); - expect(result.items[0].recordId).toBe('rec_A'); - }); - - it('should filter comments only', async () => { - const feed = new InMemoryFeedAdapter(); - await feed.createFeedItem(commentInput({ type: 'comment', body: 'comment' })); - await feed.createFeedItem(commentInput({ type: 'field_change' })); - - const result = await feed.listFeed({ - object: 'account', - recordId: 'rec_123', - filter: 'comments_only', - }); - expect(result.items).toHaveLength(1); - expect(result.items[0].type).toBe('comment'); - }); - - it('should filter changes only', async () => { - const feed = new InMemoryFeedAdapter(); - await feed.createFeedItem(commentInput({ type: 'comment' })); - await feed.createFeedItem(commentInput({ type: 'field_change' })); - - const result = await feed.listFeed({ - object: 'account', - recordId: 'rec_123', - filter: 'changes_only', - }); - expect(result.items).toHaveLength(1); - expect(result.items[0].type).toBe('field_change'); - }); - - it('should filter tasks only', async () => { - const feed = new InMemoryFeedAdapter(); - await feed.createFeedItem(commentInput({ type: 'comment' })); - await feed.createFeedItem(commentInput({ type: 'task' })); - - const result = await feed.listFeed({ - object: 'account', - recordId: 'rec_123', - filter: 'tasks_only', - }); - expect(result.items).toHaveLength(1); - expect(result.items[0].type).toBe('task'); - }); - - it('should paginate with limit and cursor', async () => { - const feed = new InMemoryFeedAdapter(); - await feed.createFeedItem(commentInput({ body: 'A' })); - await feed.createFeedItem(commentInput({ body: 'B' })); - await feed.createFeedItem(commentInput({ body: 'C' })); - - // First page - const page1 = await feed.listFeed({ - object: 'account', - recordId: 'rec_123', - limit: 2, - }); - expect(page1.items).toHaveLength(2); - expect(page1.hasMore).toBe(true); - expect(page1.nextCursor).toBeDefined(); - - // Second page - const page2 = await feed.listFeed({ - object: 'account', - recordId: 'rec_123', - limit: 2, - cursor: page1.nextCursor, - }); - expect(page2.items).toHaveLength(1); - expect(page2.hasMore).toBe(false); - }); - - // ========================================== - // Threading - // ========================================== - - it('should support threaded replies and track reply count', async () => { - const feed = new InMemoryFeedAdapter(); - const parent = await feed.createFeedItem(commentInput({ body: 'parent' })); - - await feed.createFeedItem(commentInput({ - body: 'reply 1', - parentId: parent.id, - })); - - const updatedParent = await feed.getFeedItem(parent.id); - expect(updatedParent!.replyCount).toBe(1); - }); - - it('should decrement reply count on reply deletion', async () => { - const feed = new InMemoryFeedAdapter(); - const parent = await feed.createFeedItem(commentInput({ body: 'parent' })); - const reply = await feed.createFeedItem(commentInput({ - body: 'reply', - parentId: parent.id, - })); - - await feed.deleteFeedItem(reply.id); - - const updatedParent = await feed.getFeedItem(parent.id); - expect(updatedParent!.replyCount).toBe(0); - }); - - it('should throw when creating a reply with invalid parent', async () => { - const feed = new InMemoryFeedAdapter(); - await expect( - feed.createFeedItem(commentInput({ parentId: 'nonexistent' })), - ).rejects.toThrow(/Parent feed item not found/); - }); - - // ========================================== - // Reactions - // ========================================== - - it('should add a reaction to a feed item', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - const reactions = await feed.addReaction(item.id, '👍', 'user_1'); - expect(reactions).toHaveLength(1); - expect(reactions[0].emoji).toBe('👍'); - expect(reactions[0].userIds).toEqual(['user_1']); - expect(reactions[0].count).toBe(1); - }); - - it('should add multiple users to the same reaction', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.addReaction(item.id, '👍', 'user_1'); - const reactions = await feed.addReaction(item.id, '👍', 'user_2'); - - expect(reactions[0].userIds).toEqual(['user_1', 'user_2']); - expect(reactions[0].count).toBe(2); - }); - - it('should support multiple emoji types on the same item', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.addReaction(item.id, '👍', 'user_1'); - const reactions = await feed.addReaction(item.id, '❤️', 'user_1'); - - expect(reactions).toHaveLength(2); - }); - - it('should throw when adding duplicate reaction', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.addReaction(item.id, '👍', 'user_1'); - await expect(feed.addReaction(item.id, '👍', 'user_1')) - .rejects.toThrow(/Reaction already exists/); - }); - - it('should remove a reaction', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.addReaction(item.id, '👍', 'user_1'); - await feed.addReaction(item.id, '👍', 'user_2'); - - const reactions = await feed.removeReaction(item.id, '👍', 'user_1'); - expect(reactions[0].userIds).toEqual(['user_2']); - expect(reactions[0].count).toBe(1); - }); - - it('should remove reaction entry when last user removes', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.addReaction(item.id, '👍', 'user_1'); - const reactions = await feed.removeReaction(item.id, '👍', 'user_1'); - - expect(reactions).toHaveLength(0); - }); - - it('should throw when removing a non-existent reaction', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await expect(feed.removeReaction(item.id, '👍', 'user_1')) - .rejects.toThrow(/Reaction not found/); - }); - - it('should throw when adding/removing reaction on non-existent item', async () => { - const feed = new InMemoryFeedAdapter(); - - await expect(feed.addReaction('nonexistent', '👍', 'user_1')) - .rejects.toThrow(/Feed item not found/); - await expect(feed.removeReaction('nonexistent', '👍', 'user_1')) - .rejects.toThrow(/Feed item not found/); - }); - - // ========================================== - // Subscriptions - // ========================================== - - it('should start with zero subscriptions', () => { - const feed = new InMemoryFeedAdapter(); - expect(feed.getSubscriptionCount()).toBe(0); - }); - - it('should subscribe to record notifications', async () => { - const feed = new InMemoryFeedAdapter(); - - const sub = await feed.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - events: ['comment', 'field_change'], - channels: ['in_app', 'email'], - }); - - expect(sub.object).toBe('account'); - expect(sub.recordId).toBe('rec_123'); - expect(sub.userId).toBe('user_1'); - expect(sub.events).toEqual(['comment', 'field_change']); - expect(sub.channels).toEqual(['in_app', 'email']); - expect(sub.active).toBe(true); - expect(sub.createdAt).toBeDefined(); - expect(feed.getSubscriptionCount()).toBe(1); - }); - - it('should use default events and channels', async () => { - const feed = new InMemoryFeedAdapter(); - - const sub = await feed.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - }); - - expect(sub.events).toEqual(['all']); - expect(sub.channels).toEqual(['in_app']); - }); - - it('should update existing subscription instead of creating duplicate', async () => { - const feed = new InMemoryFeedAdapter(); - - await feed.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - events: ['comment'], - }); - - const updated = await feed.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - events: ['comment', 'field_change'], - }); - - expect(feed.getSubscriptionCount()).toBe(1); - expect(updated.events).toEqual(['comment', 'field_change']); - }); - - it('should get a subscription by record and user', async () => { - const feed = new InMemoryFeedAdapter(); - - await feed.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - }); - - const sub = await feed.getSubscription('account', 'rec_123', 'user_1'); - expect(sub).not.toBeNull(); - expect(sub!.userId).toBe('user_1'); - }); - - it('should return null for non-existent subscription', async () => { - const feed = new InMemoryFeedAdapter(); - const sub = await feed.getSubscription('account', 'rec_123', 'user_1'); - expect(sub).toBeNull(); - }); - - it('should unsubscribe from record notifications', async () => { - const feed = new InMemoryFeedAdapter(); - - await feed.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - }); - - const result = await feed.unsubscribe('account', 'rec_123', 'user_1'); - expect(result).toBe(true); - expect(feed.getSubscriptionCount()).toBe(0); - }); - - it('should return false when unsubscribing without existing subscription', async () => { - const feed = new InMemoryFeedAdapter(); - const result = await feed.unsubscribe('account', 'rec_123', 'user_1'); - expect(result).toBe(false); - }); - - // ========================================== - // Edge cases - // ========================================== - - it('should create feed items with mentions', async () => { - const feed = new InMemoryFeedAdapter(); - - const item = await feed.createFeedItem(commentInput({ - body: 'Hello @jane', - mentions: [{ type: 'user', id: 'user_2', name: 'Jane', offset: 6, length: 5 }], - })); - - expect(item.mentions).toHaveLength(1); - expect(item.mentions![0].name).toBe('Jane'); - }); - - it('should create feed items with field changes', async () => { - const feed = new InMemoryFeedAdapter(); - - const item = await feed.createFeedItem({ - object: 'account', - recordId: 'rec_123', - type: 'field_change', - actor: { type: 'user', id: 'user_1' }, - changes: [ - { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' }, - ], - }); - - expect(item.type).toBe('field_change'); - expect(item.changes).toHaveLength(1); - expect(item.changes![0].field).toBe('status'); - }); - - it('should return unique feed item IDs', async () => { - const feed = new InMemoryFeedAdapter(); - - const item1 = await feed.createFeedItem(commentInput({ body: 'A' })); - const item2 = await feed.createFeedItem(commentInput({ body: 'B' })); - const item3 = await feed.createFeedItem(commentInput({ body: 'C' })); - - expect(item1.id).not.toBe(item2.id); - expect(item2.id).not.toBe(item3.id); - }); - - it('should persist reaction state in the feed item', async () => { - const feed = new InMemoryFeedAdapter(); - const item = await feed.createFeedItem(commentInput()); - - await feed.addReaction(item.id, '👍', 'user_1'); - - const fetched = await feed.getFeedItem(item.id); - expect(fetched!.reactions).toHaveLength(1); - expect(fetched!.reactions![0].emoji).toBe('👍'); - }); -}); diff --git a/packages/services/service-feed/src/in-memory-feed-adapter.ts b/packages/services/service-feed/src/in-memory-feed-adapter.ts deleted file mode 100644 index f22683b0cc..0000000000 --- a/packages/services/service-feed/src/in-memory-feed-adapter.ts +++ /dev/null @@ -1,337 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { - IFeedService, - CreateFeedItemInput, - UpdateFeedItemInput, - ListFeedOptions, - FeedListResult, - SubscribeInput, -} from '@objectstack/spec/contracts'; -import type { FeedItem, Reaction } from '@objectstack/spec/data'; -import type { RecordSubscription } from '@objectstack/spec/data'; - -/** - * Default safety cap on stored feed items. This adapter is process-local and - * non-durable (the v1 single-instance contract — see launch-readiness.md P0-5), - * so an unbounded store would grow until the pod OOMs. The cap is a backstop: - * `createFeedItem` throws loudly once it's reached rather than letting memory - * grow without bound. Operators who need a higher ceiling raise `maxItems`; - * `maxItems: 0` opts out entirely (unbounded — only for tests / short-lived - * processes). - */ -export const DEFAULT_MAX_FEED_ITEMS = 100_000; - -/** - * Configuration options for InMemoryFeedAdapter. - */ -export interface InMemoryFeedAdapterOptions { - /** - * Maximum number of feed items to store. Defaults to - * {@link DEFAULT_MAX_FEED_ITEMS}; `0` = unbounded (explicit opt-out). - */ - maxItems?: number; -} - -/** - * In-memory Feed/Chatter adapter implementing IFeedService. - * - * Uses Map-backed stores for feed items, reactions, and subscriptions. - * Supports feed CRUD, emoji reactions, threaded replies, and record subscriptions. - * - * Suitable for single-process environments, development, and testing. - * For production deployments, use a database-backed adapter. - * - * @example - * ```ts - * const feed = new InMemoryFeedAdapter(); - * - * const item = await feed.createFeedItem({ - * object: 'account', - * recordId: 'rec_123', - * type: 'comment', - * actor: { type: 'user', id: 'user_1', name: 'Alice' }, - * body: 'Great progress!', - * }); - * - * const list = await feed.listFeed({ object: 'account', recordId: 'rec_123' }); - * ``` - */ -export class InMemoryFeedAdapter implements IFeedService { - private readonly items = new Map(); - private counter = 0; - private readonly subscriptions = new Map(); - private readonly maxItems: number; - - constructor(options: InMemoryFeedAdapterOptions = {}) { - this.maxItems = options.maxItems ?? DEFAULT_MAX_FEED_ITEMS; - } - - async listFeed(options: ListFeedOptions): Promise { - let items = Array.from(this.items.values()).filter( - (item) => item.object === options.object && item.recordId === options.recordId, - ); - - // Apply filter - if (options.filter && options.filter !== 'all') { - items = items.filter((item) => { - switch (options.filter) { - case 'comments_only': - return item.type === 'comment'; - case 'changes_only': - return item.type === 'field_change'; - case 'tasks_only': - return item.type === 'task'; - default: - return true; - } - }); - } - - // Sort reverse chronological (stable: break ties by ID descending) - items.sort((a, b) => { - const timeDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (timeDiff !== 0) return timeDiff; - return b.id < a.id ? -1 : b.id > a.id ? 1 : 0; - }); - - const total = items.length; - const limit = options.limit ?? 20; - - // Cursor-based pagination - let startIndex = 0; - if (options.cursor) { - const cursorIndex = items.findIndex((item) => item.id === options.cursor); - if (cursorIndex >= 0) { - startIndex = cursorIndex + 1; - } - } - - const page = items.slice(startIndex, startIndex + limit); - const hasMore = startIndex + limit < total; - - return { - items: page, - total, - nextCursor: hasMore && page.length > 0 ? page[page.length - 1].id : undefined, - hasMore, - }; - } - - async createFeedItem(input: CreateFeedItemInput): Promise { - if (this.maxItems > 0 && this.items.size >= this.maxItems) { - throw new Error( - `Maximum feed item limit reached (${this.maxItems}). ` + - 'Delete existing items before adding new ones.', - ); - } - - const id = `feed_${++this.counter}`; - const now = new Date().toISOString(); - - // Increment parent reply count if threading - if (input.parentId) { - const parent = this.items.get(input.parentId); - if (!parent) { - throw new Error(`Parent feed item not found: ${input.parentId}`); - } - const updatedParent: FeedItem = { - ...parent, - replyCount: (parent.replyCount ?? 0) + 1, - updatedAt: now, - }; - this.items.set(parent.id, updatedParent); - } - - const item: FeedItem = { - id, - type: input.type as FeedItem['type'], - object: input.object, - recordId: input.recordId, - actor: { - type: input.actor.type, - id: input.actor.id, - ...(input.actor.name ? { name: input.actor.name } : {}), - ...(input.actor.avatarUrl ? { avatarUrl: input.actor.avatarUrl } : {}), - }, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.mentions ? { mentions: input.mentions } : {}), - ...(input.changes ? { changes: input.changes } : {}), - ...(input.parentId ? { parentId: input.parentId } : {}), - visibility: input.visibility ?? 'public', - replyCount: 0, - isEdited: false, - pinned: false, - starred: false, - createdAt: now, - }; - - this.items.set(id, item); - return item; - } - - async updateFeedItem(feedId: string, input: UpdateFeedItemInput): Promise { - const existing = this.items.get(feedId); - if (!existing) { - throw new Error(`Feed item not found: ${feedId}`); - } - - const now = new Date().toISOString(); - const updated: FeedItem = { - ...existing, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.mentions !== undefined ? { mentions: input.mentions } : {}), - ...(input.visibility !== undefined ? { visibility: input.visibility } : {}), - updatedAt: now, - editedAt: now, - isEdited: true, - }; - - this.items.set(feedId, updated); - return updated; - } - - async deleteFeedItem(feedId: string): Promise { - const item = this.items.get(feedId); - if (!item) { - throw new Error(`Feed item not found: ${feedId}`); - } - - // Decrement parent reply count if threaded - if (item.parentId) { - const parent = this.items.get(item.parentId); - if (parent) { - const updatedParent: FeedItem = { - ...parent, - replyCount: Math.max(0, (parent.replyCount ?? 0) - 1), - }; - this.items.set(parent.id, updatedParent); - } - } - - this.items.delete(feedId); - } - - async getFeedItem(feedId: string): Promise { - return this.items.get(feedId) ?? null; - } - - async addReaction(feedId: string, emoji: string, userId: string): Promise { - const item = this.items.get(feedId); - if (!item) { - throw new Error(`Feed item not found: ${feedId}`); - } - - const reactions = [...(item.reactions ?? [])]; - const existing = reactions.find((r) => r.emoji === emoji); - - if (existing) { - if (existing.userIds.includes(userId)) { - throw new Error(`Reaction already exists: ${emoji} by ${userId}`); - } - existing.userIds = [...existing.userIds, userId]; - existing.count = existing.userIds.length; - } else { - reactions.push({ emoji, userIds: [userId], count: 1 }); - } - - const updated: FeedItem = { ...item, reactions }; - this.items.set(feedId, updated); - return reactions; - } - - async removeReaction(feedId: string, emoji: string, userId: string): Promise { - const item = this.items.get(feedId); - if (!item) { - throw new Error(`Feed item not found: ${feedId}`); - } - - let reactions = [...(item.reactions ?? [])]; - const existing = reactions.find((r) => r.emoji === emoji); - - if (!existing || !existing.userIds.includes(userId)) { - throw new Error(`Reaction not found: ${emoji} by ${userId}`); - } - - existing.userIds = existing.userIds.filter((id) => id !== userId); - existing.count = existing.userIds.length; - - // Remove reaction entry if no users left - reactions = reactions.filter((r) => r.count > 0); - - const updated: FeedItem = { ...item, reactions }; - this.items.set(feedId, updated); - return reactions; - } - - async subscribe(input: SubscribeInput): Promise { - const key = this.subscriptionKey(input.object, input.recordId, input.userId); - const existing = this.findSubscription(input.object, input.recordId, input.userId); - - if (existing) { - // Update existing subscription - const updated: RecordSubscription = { - ...existing, - events: input.events ?? existing.events, - channels: input.channels ?? existing.channels, - active: true, - }; - this.subscriptions.set(key, updated); - return updated; - } - - const now = new Date().toISOString(); - const subscription: RecordSubscription = { - object: input.object, - recordId: input.recordId, - userId: input.userId, - events: input.events ?? ['all'], - channels: input.channels ?? ['in_app'], - active: true, - createdAt: now, - }; - - this.subscriptions.set(key, subscription); - return subscription; - } - - async unsubscribe(object: string, recordId: string, userId: string): Promise { - const key = this.subscriptionKey(object, recordId, userId); - return this.subscriptions.delete(key); - } - - async getSubscription( - object: string, - recordId: string, - userId: string, - ): Promise { - return this.findSubscription(object, recordId, userId); - } - - /** - * Get the total number of feed items stored. - */ - getItemCount(): number { - return this.items.size; - } - - /** - * Get the total number of subscriptions stored. - */ - getSubscriptionCount(): number { - return this.subscriptions.size; - } - - private subscriptionKey(object: string, recordId: string, userId: string): string { - return `${object}:${recordId}:${userId}`; - } - - private findSubscription( - object: string, - recordId: string, - userId: string, - ): RecordSubscription | null { - const key = this.subscriptionKey(object, recordId, userId); - return this.subscriptions.get(key) ?? null; - } -} diff --git a/packages/services/service-feed/src/index.ts b/packages/services/service-feed/src/index.ts deleted file mode 100644 index c3e7b6320c..0000000000 --- a/packages/services/service-feed/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -export { FeedServicePlugin } from './feed-service-plugin.js'; -export type { FeedServicePluginOptions } from './feed-service-plugin.js'; -export { InMemoryFeedAdapter } from './in-memory-feed-adapter.js'; -export type { InMemoryFeedAdapterOptions } from './in-memory-feed-adapter.js'; - -// Feed Service Objects (metadata definitions) -export { FeedItem, FeedReaction, RecordSubscription } from './objects/index.js'; diff --git a/packages/services/service-feed/src/objects/feed-item.object.ts b/packages/services/service-feed/src/objects/feed-item.object.ts deleted file mode 100644 index 4338829795..0000000000 --- a/packages/services/service-feed/src/objects/feed-item.object.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * Feed Item Object - * - * System object for storing feed/chatter items including comments, - * field changes, tasks, events, and system activities. - * - * Belongs to `service-feed` package per "protocol + service ownership" pattern. - */ -export const FeedItem = ObjectSchema.create({ - name: 'feed_item', - label: 'Feed Item', - pluralLabel: 'Feed Items', - icon: 'message-square', - description: 'Unified activity timeline entries (comments, field changes, tasks, events)', - titleFormat: '{type}: {body}', - compactLayout: ['type', 'object', 'record_id', 'created_at'], - - fields: { - id: Field.text({ - label: 'Feed Item ID', - required: true, - readonly: true, - }), - - type: Field.select({ - label: 'Type', - required: true, - options: [ - { label: 'Comment', value: 'comment' }, - { label: 'Field Change', value: 'field_change' }, - { label: 'Task', value: 'task' }, - { label: 'Event', value: 'event' }, - { label: 'Email', value: 'email' }, - { label: 'Call', value: 'call' }, - { label: 'Note', value: 'note' }, - { label: 'File', value: 'file' }, - { label: 'Record Create', value: 'record_create' }, - { label: 'Record Delete', value: 'record_delete' }, - { label: 'Approval', value: 'approval' }, - { label: 'Sharing', value: 'sharing' }, - { label: 'System', value: 'system' }, - ], - }), - - object: Field.text({ - label: 'Object Name', - required: true, - searchable: true, - }), - - record_id: Field.text({ - label: 'Record ID', - required: true, - searchable: true, - }), - - actor_type: Field.select({ - label: 'Actor Type', - required: true, - options: [ - { label: 'User', value: 'user' }, - { label: 'System', value: 'system' }, - { label: 'Service', value: 'service' }, - { label: 'Automation', value: 'automation' }, - ], - }), - - actor_id: Field.text({ - label: 'Actor ID', - required: true, - }), - - actor_name: Field.text({ - label: 'Actor Name', - }), - - actor_avatar_url: Field.url({ - label: 'Actor Avatar URL', - }), - - body: Field.textarea({ - label: 'Body', - description: 'Rich text body (Markdown supported)', - }), - - mentions: Field.textarea({ - label: 'Mentions', - description: 'Array of @mention objects (JSON)', - }), - - changes: Field.textarea({ - label: 'Field Changes', - description: 'Array of field change entries (JSON)', - }), - - reactions: Field.textarea({ - label: 'Reactions', - description: 'Array of emoji reaction objects (JSON)', - }), - - parent_id: Field.lookup('feed_item', { - label: 'Parent Feed Item', - description: 'For threaded replies', - }), - - reply_count: Field.number({ - label: 'Reply Count', - defaultValue: 0, - }), - - visibility: Field.select({ - label: 'Visibility', - defaultValue: 'public', - options: [ - { label: 'Public', value: 'public' }, - { label: 'Internal', value: 'internal' }, - { label: 'Private', value: 'private' }, - ], - }), - - is_edited: Field.boolean({ - label: 'Is Edited', - defaultValue: false, - }), - - edited_at: Field.datetime({ - label: 'Edited At', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - }, - - indexes: [ - { fields: ['object', 'record_id'], unique: false }, - { fields: ['actor_id'], unique: false }, - { fields: ['parent_id'], unique: false }, - { fields: ['created_at'], unique: false }, - ], - - enable: { - trackHistory: false, - searchable: true, - apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], - trash: false, - mru: false, - }, -}); diff --git a/packages/services/service-feed/src/objects/feed-reaction.object.ts b/packages/services/service-feed/src/objects/feed-reaction.object.ts deleted file mode 100644 index 99ff8cfb3a..0000000000 --- a/packages/services/service-feed/src/objects/feed-reaction.object.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * Feed Reaction Object - * - * System object for storing individual emoji reactions on feed items. - * Each row represents one user's reaction on one feed item. - * - * Belongs to `service-feed` package per "protocol + service ownership" pattern. - */ -export const FeedReaction = ObjectSchema.create({ - name: 'feed_reaction', - label: 'Feed Reaction', - pluralLabel: 'Feed Reactions', - icon: 'smile', - description: 'Emoji reactions on feed items', - titleFormat: '{emoji} by {user_id}', - compactLayout: ['feed_item_id', 'emoji', 'user_id'], - - fields: { - id: Field.text({ - label: 'Reaction ID', - required: true, - readonly: true, - }), - - feed_item_id: Field.lookup('feed_item', { - label: 'Feed Item', - required: true, - }), - - emoji: Field.text({ - label: 'Emoji', - required: true, - description: 'Emoji character or shortcode (e.g., "👍", ":thumbsup:")', - }), - - user_id: Field.lookup('sys_user', { - label: 'User', - required: true, - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - }, - - indexes: [ - { fields: ['feed_item_id', 'emoji', 'user_id'], unique: true }, - { fields: ['feed_item_id'], unique: false }, - { fields: ['user_id'], unique: false }, - ], - - enable: { - trackHistory: false, - searchable: false, - apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'delete'], - trash: false, - mru: false, - }, -}); diff --git a/packages/services/service-feed/src/objects/index.ts b/packages/services/service-feed/src/objects/index.ts deleted file mode 100644 index ea5090ac11..0000000000 --- a/packages/services/service-feed/src/objects/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Feed Service Objects - * - * ObjectQL-based object definitions for Feed/Chatter database schema. - * These objects define the persistent storage model for feed items, - * reactions, and record subscriptions. - */ - -export { FeedItem } from './feed-item.object.js'; -export { FeedReaction } from './feed-reaction.object.js'; -export { RecordSubscription } from './record-subscription.object.js'; diff --git a/packages/services/service-feed/src/objects/record-subscription.object.ts b/packages/services/service-feed/src/objects/record-subscription.object.ts deleted file mode 100644 index 9ac4ca3d4d..0000000000 --- a/packages/services/service-feed/src/objects/record-subscription.object.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * Record Subscription Object - * - * System object for storing record-level notification subscriptions. - * Enables Airtable-style bell icon for record change notifications. - * - * Belongs to `service-feed` package per "protocol + service ownership" pattern. - */ -export const RecordSubscription = ObjectSchema.create({ - name: 'record_subscription', - label: 'Record Subscription', - pluralLabel: 'Record Subscriptions', - icon: 'bell', - description: 'Record-level notification subscriptions for feed events', - titleFormat: '{object}/{record_id} — {user_id}', - compactLayout: ['object', 'record_id', 'user_id', 'active'], - - fields: { - id: Field.text({ - label: 'Subscription ID', - required: true, - readonly: true, - }), - - object: Field.text({ - label: 'Object Name', - required: true, - }), - - record_id: Field.text({ - label: 'Record ID', - required: true, - }), - - user_id: Field.lookup('sys_user', { - label: 'User', - required: true, - }), - - events: Field.textarea({ - label: 'Subscribed Events', - description: 'Array of event types: comment, mention, field_change, task, approval, all (JSON)', - }), - - channels: Field.textarea({ - label: 'Notification Channels', - description: 'Array of channels: in_app, email, push, slack (JSON)', - }), - - active: Field.boolean({ - label: 'Active', - defaultValue: true, - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - }, - - indexes: [ - { fields: ['object', 'record_id', 'user_id'], unique: true }, - { fields: ['user_id'], unique: false }, - { fields: ['object', 'record_id'], unique: false }, - ], - - enable: { - trackHistory: false, - searchable: false, - apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], - trash: false, - mru: false, - }, -}); diff --git a/packages/services/service-feed/tsconfig.json b/packages/services/service-feed/tsconfig.json deleted file mode 100644 index 0b8b99d882..0000000000 --- a/packages/services/service-feed/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "types": [ - "node" - ] - }, - "include": [ - "src" - ], - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index aef1ac5838..e794583a45 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -15,8 +15,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * 2. Files can be moved between buckets / storage tiers without breaking links. * 3. Lifecycle status (uploading → committed → deleted) can be tracked. * - * Belongs to `@objectstack/service-storage` per the - * "protocol + service ownership" pattern used by `service-feed`. + * Belongs to `@objectstack/service-storage` per the platform's + * "protocol + service ownership" pattern. */ export const SystemFile = ObjectSchema.create({ name: 'sys_file', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01b1d23db3..64b59298bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,9 +492,6 @@ importers: '@objectstack/service-datasource': specifier: workspace:* version: link:../services/service-datasource - '@objectstack/service-feed': - specifier: workspace:* - version: link:../services/service-feed '@objectstack/service-job': specifier: workspace:* version: link:../services/service-job @@ -1827,25 +1824,6 @@ importers: specifier: ^4.1.9 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.2)(msw@2.14.6(@types/node@25.9.3)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/services/service-feed: - dependencies: - '@objectstack/core': - specifier: workspace:* - version: link:../../core - '@objectstack/spec': - specifier: workspace:* - version: link:../../spec - devDependencies: - '@types/node': - specifier: ^25.9.3 - version: 25.9.3 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - vitest: - specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.2)(msw@2.14.6(@types/node@25.9.3)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/services/service-i18n: dependencies: '@objectstack/core':