From 139ef530d2a0988752cad5ffadcc8847d32199d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 06:42:59 +0000 Subject: [PATCH] feat(spec)!: retire vestigial feed contracts + protocol surface (#1959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0052 §5 follow-up. `service-feed`'s runtime was deleted in #1955; `sys_comment` / `sys_activity` are the canonical record-collaboration / timeline backend. This removes the dead type surface that still pointed at the deleted runtime — every removed method was already unreachable (feed REST was never mounted → 404; the protocol impl was never wired with a feed service, so requireFeedService() could only throw). No behavior change. Removed: - spec: contracts/feed-service (IFeedService), api/feed-api.zod (FeedApiContracts + request/response types), data/subscription.zod, and the four feed regions of api/protocol.zod (imports, z.function stubs, type re-exports, FeedProtocol interface + mixin) — plus their tests and barrels. - metadata-protocol: the getFeedService ctor param, requireFeedService(), and the 14 feed method implementations. Constructor is now (engine, getServicesRegistry?, environmentId?). - objectql: protocol-feed.test.ts + the positional call-site updates. - client: the feed = {...} SDK accessor and its feed response type imports. Kept: data/feed.zod trimmed to FeedItemType / FeedFilterMode (live UI config for the record activity component). Regenerated api-surface.json, json-schema.manifest.json, and content/docs/references. The residual discovery/dispatcher feed capability surface is tracked as a follow-up (#3180). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HtN5uPoPnFzkSJ2HcwzgtG --- .changeset/retire-feed-contracts.md | 53 ++ content/docs/references/api/feed-api.mdx | 542 ----------- content/docs/references/api/index.mdx | 1 - content/docs/references/api/meta.json | 1 - content/docs/references/data/feed.mdx | 119 +-- content/docs/references/data/index.mdx | 1 - content/docs/references/data/meta.json | 2 - content/docs/references/data/notification.mdx | 35 - content/docs/references/data/subscription.mdx | 58 -- .../0052-audit-is-not-the-activity-feed.md | 12 +- docs/adr/0076-objectql-core-tiering.md | 6 +- packages/client/src/client.feed.test.ts | 273 ------ packages/client/src/index.ts | 214 +---- packages/metadata-protocol/src/protocol.ts | 163 ---- .../objectql/src/overlay-precedence.test.ts | 1 - packages/objectql/src/plugin.ts | 1 - packages/objectql/src/protocol-feed.test.ts | 303 ------- .../src/protocol-lock-enforcement.test.ts | 3 +- .../src/protocol-meta-types-rich.test.ts | 2 +- packages/objectql/src/protocol-meta.test.ts | 1 - .../src/protocol-registry-shadow.test.ts | 2 +- packages/spec/api-surface.json | 87 -- packages/spec/json-schema.manifest.json | 42 - packages/spec/src/api/feed-api.test.ts | 850 ------------------ packages/spec/src/api/feed-api.zod.ts | 583 ------------ packages/spec/src/api/index.ts | 1 - packages/spec/src/api/protocol.zod.ts | 113 +-- .../spec/src/contracts/feed-service.test.ts | 194 ---- packages/spec/src/contracts/feed-service.ts | 222 ----- packages/spec/src/contracts/index.ts | 1 - packages/spec/src/data/feed.test.ts | 307 +------ packages/spec/src/data/feed.zod.ts | 147 +-- packages/spec/src/data/index.ts | 6 +- packages/spec/src/data/subscription.test.ts | 103 --- packages/spec/src/data/subscription.zod.ts | 61 -- skills/objectstack-ui/references/_index.md | 2 +- 36 files changed, 99 insertions(+), 4413 deletions(-) create mode 100644 .changeset/retire-feed-contracts.md delete mode 100644 content/docs/references/api/feed-api.mdx delete mode 100644 content/docs/references/data/notification.mdx delete mode 100644 content/docs/references/data/subscription.mdx delete mode 100644 packages/client/src/client.feed.test.ts delete mode 100644 packages/objectql/src/protocol-feed.test.ts delete mode 100644 packages/spec/src/api/feed-api.test.ts delete mode 100644 packages/spec/src/api/feed-api.zod.ts delete mode 100644 packages/spec/src/contracts/feed-service.test.ts delete mode 100644 packages/spec/src/contracts/feed-service.ts delete mode 100644 packages/spec/src/data/subscription.test.ts delete mode 100644 packages/spec/src/data/subscription.zod.ts diff --git a/.changeset/retire-feed-contracts.md b/.changeset/retire-feed-contracts.md new file mode 100644 index 0000000000..91021af2ec --- /dev/null +++ b/.changeset/retire-feed-contracts.md @@ -0,0 +1,53 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/client": minor +"@objectstack/objectql": patch +--- + +**Breaking (npm type surface): retire the vestigial feed contracts + protocol surface (ADR-0052 §5 follow-up, #1959).** + +The `service-feed` runtime was deleted in #1955; `sys_comment` / `sys_activity` +are the canonical record-collaboration/timeline backend. This removes the dead +type surface that still pointed at the deleted runtime — every removed method was +already unreachable (the feed REST route was never mounted → 404; the protocol +implementation was never wired with a feed service, so `requireFeedService()` +could only throw). No behavior changes. + +No authorable metadata key is removed (the `feeds:` object capability flag and +the `RecordActivity` UI component config are unchanged), so `PROTOCOL_MAJOR` +stays 15 and this ships as `minor` rather than a protocol major. + +FROM → TO migration for every removed export: + +- `@objectstack/spec/contracts` — `IFeedService`, `CreateFeedItemInput`, + `UpdateFeedItemInput`, `ListFeedOptions`, `FeedListResult` → **removed, no + replacement**. Comments/activity are plain records: write `sys_comment` / read + `sys_activity` via the data engine or the REST data API. +- `@objectstack/spec/api` — `FeedApiContracts`, `FeedApiErrorCode`, + `FeedProtocol`, and all feed request/response schemas + types (`GetFeed*`, + `CreateFeedItem*`, `UpdateFeedItem*`, `DeleteFeedItem*`, `AddReaction*`, + `RemoveReaction*`, `PinFeedItem*`, `UnpinFeedItem*`, `StarFeedItem*`, + `UnstarFeedItem*`, `SearchFeed*`, `GetChangelog*`, `ChangelogEntry`, + `SubscribeRequest/Response`, `FeedUnsubscribeRequest`, `UnsubscribeResponse`, + `FeedPathParams`, `FeedItemPathParams`, `FeedListFilterType`) → **removed**. Use + the data API against `sys_comment` / `sys_activity` (`/api/v1/data/sys_comment/…`); + reactions and threaded replies are fields on `sys_comment`. +- `@objectstack/spec/data` — `FeedItemSchema`/`FeedItem`, `FeedActorSchema`/`FeedActor`, + `MentionSchema`/`Mention`, `ReactionSchema`/`Reaction`, + `FieldChangeEntrySchema`/`FieldChangeEntry`, `FeedVisibility`, + `RecordSubscriptionSchema`/`RecordSubscription`, `SubscriptionEventType`, and the + `data`-namespace `NotificationChannel` → **removed**. `FeedItemType` and + `FeedFilterMode` are **kept** (live UI activity-timeline config). For notification + channels use `NotificationChannelSchema` from `@objectstack/spec/system`. +- `@objectstack/client` — `client.feed.*` (`list` / `create` / `update` / `delete` / + `addReaction` / `removeReaction` / `pin` / `unpin` / `star` / `unstar` / `search` / + `getChangelog` / `subscribe` / `unsubscribe`) and the re-exported feed response + types → **removed**. One-line fix: use `client.data.*` on `sys_comment` / + `sys_activity`, e.g. `client.data.create('sys_comment', { object, record_id, body })` + and `client.data.find('sys_activity', { filters: [['record_id', '=', id]] })`. +- `@objectstack/metadata-protocol` — `ObjectStackProtocolImplementation` no longer + implements the 14 feed methods; its constructor + `(engine, getServicesRegistry?, getFeedService?, environmentId?)` becomes + `(engine, getServicesRegistry?, environmentId?)`. One-line fix: delete the third + argument. diff --git a/content/docs/references/api/feed-api.mdx b/content/docs/references/api/feed-api.mdx deleted file mode 100644 index 6d91226c1d..0000000000 --- a/content/docs/references/api/feed-api.mdx +++ /dev/null @@ -1,542 +0,0 @@ ---- -title: Feed Api -description: Feed Api protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Feed / Chatter API Protocol - -Defines the HTTP interface for the unified activity timeline (Feed). - -Covers Feed CRUD, Emoji Reactions, Pin/Star, Search, Changelog, - -and Record Subscription endpoints. - -Base path: /api/data/\{object\}/\{recordId\}/feed - -@example Endpoints - -GET /api/data/\{object\}/\{recordId\}/feed — List feed items - -POST /api/data/\{object\}/\{recordId\}/feed — Create feed item - -PUT /api/data/\{object\}/\{recordId\}/feed/\{feedId\} — Update feed item - -DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\} — Delete feed item - -POST /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/reactions — Add reaction - -DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/reactions/\{emoji\} — Remove reaction - -POST /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/pin — Pin feed item - -DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/pin — Unpin feed item - -POST /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/star — Star feed item - -DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/star — Unstar feed item - -GET /api/data/\{object\}/\{recordId\}/feed/search — Search feed items - -GET /api/data/\{object\}/\{recordId\}/changelog — Get field-level changelog - -POST /api/data/\{object\}/\{recordId\}/subscribe — Subscribe - -DELETE /api/data/\{object\}/\{recordId\}/subscribe — Unsubscribe - - -**Source:** `packages/spec/src/api/feed-api.zod.ts` - - -## TypeScript Usage - -```typescript -import { AddReactionRequest, AddReactionResponse, ChangelogEntry, CreateFeedItemRequest, CreateFeedItemResponse, DeleteFeedItemRequest, DeleteFeedItemResponse, FeedApiErrorCode, FeedItemPathParams, FeedListFilterType, FeedPathParams, FeedUnsubscribeRequest, GetChangelogRequest, GetChangelogResponse, GetFeedRequest, GetFeedResponse, PinFeedItemRequest, PinFeedItemResponse, RemoveReactionRequest, RemoveReactionResponse, SearchFeedRequest, SearchFeedResponse, StarFeedItemRequest, StarFeedItemResponse, SubscribeRequest, SubscribeResponse, UnpinFeedItemRequest, UnpinFeedItemResponse, UnstarFeedItemRequest, UnstarFeedItemResponse, UnsubscribeResponse, UpdateFeedItemRequest, UpdateFeedItemResponse } from '@objectstack/spec/api'; -import type { AddReactionRequest, AddReactionResponse, ChangelogEntry, CreateFeedItemRequest, CreateFeedItemResponse, DeleteFeedItemRequest, DeleteFeedItemResponse, FeedApiErrorCode, FeedItemPathParams, FeedListFilterType, FeedPathParams, FeedUnsubscribeRequest, GetChangelogRequest, GetChangelogResponse, GetFeedRequest, GetFeedResponse, PinFeedItemRequest, PinFeedItemResponse, RemoveReactionRequest, RemoveReactionResponse, SearchFeedRequest, SearchFeedResponse, StarFeedItemRequest, StarFeedItemResponse, SubscribeRequest, SubscribeResponse, UnpinFeedItemRequest, UnpinFeedItemResponse, UnstarFeedItemRequest, UnstarFeedItemResponse, UnsubscribeResponse, UpdateFeedItemRequest, UpdateFeedItemResponse } from '@objectstack/spec/api'; - -// Validate data -const result = AddReactionRequest.parse(data); -``` - ---- - -## AddReactionRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | -| **emoji** | `string` | ✅ | Emoji character or shortcode (e.g., "👍", ":thumbsup:") | - - ---- - -## AddReactionResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ reactions: { emoji: string; userIds: string[]; count: integer }[] }` | ✅ | | - - ---- - -## ChangelogEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Changelog entry ID | -| **object** | `string` | ✅ | Object name | -| **recordId** | `string` | ✅ | Record ID | -| **actor** | `{ type: Enum<'user' \| 'system' \| 'service' \| 'automation'>; id: string; name?: string }` | ✅ | Who made the change | -| **changes** | `{ field: string; fieldLabel?: string; oldValue?: any; newValue?: any; … }[]` | ✅ | Field-level changes | -| **timestamp** | `string` | ✅ | When the change occurred | -| **source** | `string` | optional | Change source (e.g., "API", "UI", "automation") | - - ---- - -## CreateFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **type** | `Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>` | ✅ | Type of feed item to create | -| **body** | `string` | optional | Rich text body (Markdown supported) | -| **mentions** | `{ type: Enum<'user' \| 'team' \| 'record'>; id: string; name: string; offset: integer; … }[]` | optional | Mentioned users, teams, or records | -| **parentId** | `string` | optional | Parent feed item ID for threaded replies | -| **visibility** | `Enum<'public' \| 'internal' \| 'private'>` | ✅ | Visibility: public, internal, or private | - - ---- - -## CreateFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }` | ✅ | The created feed item | - - ---- - -## DeleteFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | - - ---- - -## DeleteFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ feedId: string }` | ✅ | | - - ---- - -## FeedApiErrorCode - -### Allowed Values - -* `feed_item_not_found` -* `feed_permission_denied` -* `feed_item_not_editable` -* `feed_invalid_parent` -* `reaction_already_exists` -* `reaction_not_found` -* `subscription_already_exists` -* `subscription_not_found` -* `invalid_feed_type` -* `feed_already_pinned` -* `feed_not_pinned` -* `feed_already_starred` -* `feed_not_starred` -* `feed_search_query_too_short` - - ---- - -## FeedItemPathParams - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | - - ---- - -## FeedListFilterType - -### Allowed Values - -* `all` -* `comments_only` -* `changes_only` -* `tasks_only` - - ---- - -## FeedPathParams - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | - - ---- - -## FeedUnsubscribeRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | - - ---- - -## GetChangelogRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **field** | `string` | optional | Filter changelog to a specific field name | -| **actorId** | `string` | optional | Filter changelog by actor user ID | -| **dateFrom** | `string` | optional | Filter changes after this timestamp | -| **dateTo** | `string` | optional | Filter changes before this timestamp | -| **limit** | `integer` | ✅ | Maximum number of changelog entries to return | -| **cursor** | `string` | optional | Cursor for pagination | - - ---- - -## GetChangelogResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ entries: { id: string; object: string; recordId: string; actor: object; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | - - ---- - -## GetFeedRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **type** | `Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>` | ✅ | Filter by feed item category | -| **limit** | `integer` | ✅ | Maximum number of items to return | -| **cursor** | `string` | optional | Cursor for pagination (opaque string from previous response) | - - ---- - -## GetFeedResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ items: { id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | - - ---- - -## PinFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | - - ---- - -## PinFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ feedId: string; pinned: boolean; pinnedAt: string }` | ✅ | | - - ---- - -## RemoveReactionRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | -| **emoji** | `string` | ✅ | Emoji character or shortcode to remove | - - ---- - -## RemoveReactionResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ reactions: { emoji: string; userIds: string[]; count: integer }[] }` | ✅ | | - - ---- - -## SearchFeedRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **query** | `string` | ✅ | Full-text search query against feed body content | -| **type** | `Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>` | optional | Filter by feed item category | -| **actorId** | `string` | optional | Filter by actor user ID | -| **dateFrom** | `string` | optional | Filter feed items created after this timestamp | -| **dateTo** | `string` | optional | Filter feed items created before this timestamp | -| **hasAttachments** | `boolean` | optional | Filter for items with file attachments | -| **pinnedOnly** | `boolean` | optional | Return only pinned items | -| **starredOnly** | `boolean` | optional | Return only starred items | -| **limit** | `integer` | ✅ | Maximum number of items to return | -| **cursor** | `string` | optional | Cursor for pagination | - - ---- - -## SearchFeedResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ items: { id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | - - ---- - -## StarFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | - - ---- - -## StarFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ feedId: string; starred: boolean; starredAt: string }` | ✅ | | - - ---- - -## SubscribeRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **events** | `Enum<'comment' \| 'mention' \| 'field_change' \| 'task' \| 'approval' \| 'all'>[]` | ✅ | Event types to subscribe to | -| **channels** | `Enum<'in_app' \| 'email' \| 'push' \| 'slack'>[]` | ✅ | Notification delivery channels | - - ---- - -## SubscribeResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ object: string; recordId: string; userId: string; events: Enum<'comment' \| 'mention' \| 'field_change' \| 'task' \| 'approval' \| 'all'>[]; … }` | ✅ | The created or updated subscription | - - ---- - -## UnpinFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | - - ---- - -## UnpinFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ feedId: string; pinned: boolean }` | ✅ | | - - ---- - -## UnstarFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | - - ---- - -## UnstarFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ feedId: string; starred: boolean }` | ✅ | | - - ---- - -## UnsubscribeResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ object: string; recordId: string; unsubscribed: boolean }` | ✅ | | - - ---- - -## UpdateFeedItemRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID | -| **feedId** | `string` | ✅ | Feed item ID | -| **body** | `string` | optional | Updated rich text body | -| **mentions** | `{ type: Enum<'user' \| 'team' \| 'record'>; id: string; name: string; offset: integer; … }[]` | optional | Updated mentions | -| **visibility** | `Enum<'public' \| 'internal' \| 'private'>` | optional | Updated visibility | - - ---- - -## UpdateFeedItemResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }` | ✅ | The updated feed item | - - ---- - diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index f74d77f65c..42d4680c3e 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -19,7 +19,6 @@ This section contains all protocol schemas for the api layer of ObjectStack. - diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index eefad9f2c8..b92de43606 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -16,7 +16,6 @@ "errors", "events", "export", - "feed-api", "graphql", "http", "http-cache", diff --git a/content/docs/references/data/feed.mdx b/content/docs/references/data/feed.mdx index ce30737cc7..43161ef518 100644 --- a/content/docs/references/data/feed.mdx +++ b/content/docs/references/data/feed.mdx @@ -5,11 +5,17 @@ description: Feed protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Feed Item Type +Activity-timeline UI config enums. -Unified activity types for the record timeline. +The `service-feed` backend was retired (ADR-0052 §5 / #1955); `sys_comment` / -Covers comments, field changes, tasks, events, and system activities. +`sys_activity` are the canonical record-collaboration/timeline backend. Only these + +two enums remain here — they are pure UI configuration for the record activity + +component (`RecordActivityProps` in `../[ui/component.zod.ts](/docs/references/ui/component)`), with no backend + +dependency. (A later `feed` → `activity` rename is tracked separately.) **Source:** `packages/spec/src/data/feed.zod.ts` @@ -18,28 +24,13 @@ Covers comments, field changes, tasks, events, and system activities. ## TypeScript Usage ```typescript -import { FeedActor, FeedFilterMode, FeedItem, FeedItemType, FeedVisibility, FieldChangeEntry, Mention, Reaction } from '@objectstack/spec/data'; -import type { FeedActor, FeedFilterMode, FeedItem, FeedItemType, FeedVisibility, FieldChangeEntry, Mention, Reaction } from '@objectstack/spec/data'; +import { FeedFilterMode, FeedItemType } from '@objectstack/spec/data'; +import type { FeedFilterMode, FeedItemType } from '@objectstack/spec/data'; // Validate data -const result = FeedActor.parse(data); +const result = FeedFilterMode.parse(data); ``` ---- - -## FeedActor - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'user' \| 'system' \| 'service' \| 'automation'>` | ✅ | Actor type | -| **id** | `string` | ✅ | Actor ID | -| **name** | `string` | optional | Actor display name | -| **avatarUrl** | `string` | optional | Actor avatar URL | -| **source** | `string` | optional | Source application (e.g., "Omni", "API", "Studio") | - - --- ## FeedFilterMode @@ -52,37 +43,6 @@ const result = FeedActor.parse(data); * `tasks_only` ---- - -## FeedItem - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Feed item ID | -| **type** | `Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>` | ✅ | Activity type | -| **object** | `string` | ✅ | Object name (e.g., "account") | -| **recordId** | `string` | ✅ | Record ID this feed item belongs to | -| **actor** | `{ type: Enum<'user' \| 'system' \| 'service' \| 'automation'>; id: string; name?: string; avatarUrl?: string; … }` | ✅ | Who performed this action | -| **body** | `string` | optional | Rich text body (Markdown supported) | -| **mentions** | `{ type: Enum<'user' \| 'team' \| 'record'>; id: string; name: string; offset: integer; … }[]` | optional | Mentioned users/teams/records | -| **changes** | `{ field: string; fieldLabel?: string; oldValue?: any; newValue?: any; … }[]` | optional | Field-level changes | -| **reactions** | `{ emoji: string; userIds: string[]; count: integer }[]` | optional | Emoji reactions on this item | -| **parentId** | `string` | optional | Parent feed item ID for threaded replies | -| **replyCount** | `integer` | ✅ | Number of replies | -| **pinned** | `boolean` | ✅ | Whether the feed item is pinned to the top of the timeline | -| **pinnedAt** | `string` | optional | Timestamp when the item was pinned | -| **pinnedBy** | `string` | optional | User ID who pinned the item | -| **starred** | `boolean` | ✅ | Whether the feed item is starred/bookmarked by the current user | -| **starredAt** | `string` | optional | Timestamp when the item was starred | -| **visibility** | `Enum<'public' \| 'internal' \| 'private'>` | ✅ | Visibility: public (all users), internal (team only), private (author + mentioned) | -| **createdAt** | `string` | ✅ | Creation timestamp | -| **updatedAt** | `string` | optional | Last update timestamp | -| **editedAt** | `string` | optional | When comment was last edited | -| **isEdited** | `boolean` | ✅ | Whether comment has been edited | - - --- ## FeedItemType @@ -106,58 +66,3 @@ const result = FeedActor.parse(data); --- -## FeedVisibility - -### Allowed Values - -* `public` -* `internal` -* `private` - - ---- - -## FieldChangeEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field machine name | -| **fieldLabel** | `string` | optional | Field display label | -| **oldValue** | `any` | optional | Previous value | -| **newValue** | `any` | optional | New value | -| **oldDisplayValue** | `string` | optional | Human-readable old value | -| **newDisplayValue** | `string` | optional | Human-readable new value | - - ---- - -## Mention - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'user' \| 'team' \| 'record'>` | ✅ | Mention target type | -| **id** | `string` | ✅ | Target ID | -| **name** | `string` | ✅ | Display name for rendering | -| **offset** | `integer` | ✅ | Character offset in body text | -| **length** | `integer` | ✅ | Length of mention token in body text | - - ---- - -## Reaction - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **emoji** | `string` | ✅ | Emoji character or shortcode (e.g., "👍", ":thumbsup:") | -| **userIds** | `string[]` | ✅ | Users who reacted | -| **count** | `integer` | ✅ | Total reaction count | - - ---- - diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index 7465b23881..9498ac5742 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -26,6 +26,5 @@ This section contains all protocol schemas for the data layer of ObjectStack. - diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index e6a34a191d..132f029f65 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -17,13 +17,11 @@ "hook", "hook-body", "mapping", - "notification", "object", "query", "search-engine", "seed", "seed-loader", - "subscription", "validation" ] } \ No newline at end of file diff --git a/content/docs/references/data/notification.mdx b/content/docs/references/data/notification.mdx deleted file mode 100644 index 57613fe7e9..0000000000 --- a/content/docs/references/data/notification.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Notification -description: Notification protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/data/notification.zod.ts` - - -## TypeScript Usage - -```typescript -import { NotificationChannel } from '@objectstack/spec/data'; -import type { NotificationChannel } from '@objectstack/spec/data'; - -// Validate data -const result = NotificationChannel.parse(data); -``` - ---- - -## NotificationChannel - -### Allowed Values - -* `in_app` -* `email` -* `push` -* `slack` - - ---- - diff --git a/content/docs/references/data/subscription.mdx b/content/docs/references/data/subscription.mdx deleted file mode 100644 index 2f4a516461..0000000000 --- a/content/docs/references/data/subscription.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: Subscription -description: Subscription protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Subscription Event Type - -Event types that can be subscribed to for record-level notifications. - - -**Source:** `packages/spec/src/data/subscription.zod.ts` - - -## TypeScript Usage - -```typescript -import { RecordSubscription, SubscriptionEventType } from '@objectstack/spec/data'; -import type { RecordSubscription, SubscriptionEventType } from '@objectstack/spec/data'; - -// Validate data -const result = RecordSubscription.parse(data); -``` - ---- - -## RecordSubscription - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name | -| **recordId** | `string` | ✅ | Record ID | -| **userId** | `string` | ✅ | Subscribing user ID | -| **events** | `Enum<'comment' \| 'mention' \| 'field_change' \| 'task' \| 'approval' \| 'all'>[]` | ✅ | Event types to receive notifications for | -| **channels** | `Enum<'in_app' \| 'email' \| 'push' \| 'slack'>[]` | ✅ | Notification delivery channels | -| **active** | `boolean` | ✅ | Whether the subscription is active | -| **createdAt** | `string` | ✅ | Subscription creation timestamp | - - ---- - -## SubscriptionEventType - -### Allowed Values - -* `comment` -* `mention` -* `field_change` -* `task` -* `approval` -* `all` - - ---- - 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 d16c216f0b..3df1128bd7 100644 --- a/docs/adr/0052-audit-is-not-the-activity-feed.md +++ b/docs/adr/0052-audit-is-not-the-activity-feed.md @@ -215,9 +215,15 @@ the split-brain this ADR exists to end, not extend. 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.) +`sys_activity` stand alone. The vestigial spec *contracts* — `feed-api.zod` / +`IFeedService` / `data/subscription.zod`, plus the `FeedProtocol` slice of +`protocol.zod`, the never-wired `getFeedService` plumbing in the protocol +implementation, and the client `feed` SDK accessor — were **removed in #1959**. +`data/feed.zod` is trimmed to just the `FeedItemType` / `FeedFilterMode` enums, +which are live UI config for the record activity component (`component.zod`). +(The residual discovery/dispatcher `feed` capability surface — `routes.feed`, +`WellKnownCapabilities.feed`, the `/api/v1/feed` dispatcher entry — is a small +separate follow-up, tracked as #3180.) ## 5b. Declarative activity — the platform generates it, apps don't code it diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index a92b9fe390..0b69ab0755 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -103,16 +103,16 @@ The segmentation is **spec/type-level and may start incrementally now** (define ### D10 — "protocol" is a contract, not an implementation package; distribute impls to domain packages [new — kernel review] A single `ObjectStackProtocolImplementation` facade — and any `*-protocol` *implementation* package — is the wrong end-state. Verified against source: -- The facade implements only **4 of the 11** contract domains (data, metadata, analytics, feed). The other 7 (realtime / notifications / workflow / ai / i18n / views / permissions) are **not implemented in it** — they belong to their own services or aren't implemented at all. +- The facade implements only **4 of the 11** contract domains (data, metadata, analytics, feed). The other 7 (realtime / notifications / workflow / ai / i18n / views / permissions) are **not implemented in it** — they belong to their own services or aren't implemented at all. *(Update #1959: the feed domain was retired per ADR-0052 §5 — `IFeedService`, `FeedProtocol`, and the facade's feed forwarding no longer exist, so the facade now implements 3 domains: data, metadata, analytics.)* - **Analytics uses a deliberate fallback + `replaceService` pattern (NOT a collision/bug — corrected in rev.9)**: `registerService` throws on duplicate (`core/kernel.ts`), so `ObjectQLPlugin` registers a lightweight ~66-line analytics **fallback** (so `/analytics` doesn't 404 in minimal deployments), and `AnalyticsServicePlugin`, when installed, calls **`ctx.replaceService('analytics', …)`** to swap in the full ~1.8k-LOC engine (three strategies incl. native-SQL). With service-analytics present the real engine serves (no shadowing); without it the fallback serves. The fallback duplicates *logic* only (a minor maintenance cost) and is intentional and harmless. -- **Feed is already delegated** to `IFeedService`; the facade only forwards. +- ~~**Feed is already delegated** to `IFeedService`; the facade only forwards.~~ *(Retired per ADR-0052 §5 / #1959 — `IFeedService` and the facade's feed forwarding were removed.)* - The domain service packages already exist: `service-analytics`, `service-messaging`, `service-realtime`. Target end-state: - **"protocol" names ONLY the contract** — the segmented interfaces in `@objectstack/spec/api` (D9). There is **no `*-protocol` implementation package**. - **`DataProtocol`** impl → engine-adjacent / transport (thin wire-normalizers). - **`MetadataProtocol`** impl → stays in **`@objectstack/metadata-protocol`** (name **retained** — already published; renaming churns downstream for ~0 benefit). The package's *content* converges to the metadata-management impl (it owns `sys_metadata`). The `protocol` in the name is a deliberate, low-cost naming exception from being published — the real contract lives in `@objectstack/spec/api`, not here. (Open Question #7, resolved.) -- **Analytics / Feed / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. Feed already delegates to `IFeedService`. The engine keeps only the minimal fallback it deliberately provides. +- **Analytics / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. The engine keeps only the minimal fallback it deliberately provides. *(Feed was dropped entirely per ADR-0052 §5 / #1959 — no service package, no facade forwarding.)* - **The transport/dispatcher routes each contract-slice to the owning service** (it already resolves services by name) — no central facade class. This **refines D1** (the `metadata-protocol` package was an intermediate, not the end-state) and **completes D9**. Executed at the cross-repo window with D7 / Step 2. diff --git a/packages/client/src/client.feed.test.ts b/packages/client/src/client.feed.test.ts deleted file mode 100644 index da3904bf39..0000000000 --- a/packages/client/src/client.feed.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { ObjectStackClient } from './index'; - -/** Helper: create a client with mocked fetch */ -function createMockClient(body: any, status = 200) { - const fetchMock = vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - statusText: status === 200 ? 'OK' : 'Error', - json: async () => body, - headers: new Headers() - }); - const client = new ObjectStackClient({ - baseUrl: 'http://localhost:3000', - fetch: fetchMock - }); - return { client, fetchMock }; -} - -describe('ObjectStackClient - Feed Namespace', () => { - // ========================================== - // Feed CRUD - // ========================================== - - it('feed.list should GET /api/v1/data/:object/:recordId/feed', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { items: [], total: 0, hasMore: false } - }); - - const result = await client.feed.list('account', 'rec_123', { type: 'all', limit: 10 }); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed?type=all&limit=10', - expect.objectContaining({ headers: expect.any(Object) }) - ); - expect(result.items).toEqual([]); - expect(result.hasMore).toBe(false); - }); - - it('feed.create should POST /api/v1/data/:object/:recordId/feed', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { id: 'feed_1', type: 'comment', body: 'Hello' } - }); - - const result = await client.feed.create('account', 'rec_123', { - type: 'comment', - body: 'Hello' - }); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ type: 'comment', body: 'Hello' }) - }) - ); - expect(result.id).toBe('feed_1'); - }); - - it('feed.update should PUT /api/v1/data/:object/:recordId/feed/:feedId', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { id: 'feed_1', type: 'comment', body: 'Updated' } - }); - - const result = await client.feed.update('account', 'rec_123', 'feed_1', { - body: 'Updated' - }); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ body: 'Updated' }) - }) - ); - expect(result.body).toBe('Updated'); - }); - - it('feed.delete should DELETE /api/v1/data/:object/:recordId/feed/:feedId', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { feedId: 'feed_1' } - }); - - const result = await client.feed.delete('account', 'rec_123', 'feed_1'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1', - expect.objectContaining({ method: 'DELETE' }) - ); - expect(result.feedId).toBe('feed_1'); - }); - - // ========================================== - // Reactions - // ========================================== - - it('feed.addReaction should POST reactions endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { reactions: [{ emoji: '👍', count: 1 }] } - }); - - const result = await client.feed.addReaction('account', 'rec_123', 'feed_1', '👍'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/reactions', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ emoji: '👍' }) - }) - ); - expect(result.reactions).toHaveLength(1); - }); - - it('feed.removeReaction should DELETE reactions/:emoji endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { reactions: [] } - }); - - await client.feed.removeReaction('account', 'rec_123', 'feed_1', '👍'); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/api/v1/data/account/rec_123/feed/feed_1/reactions/'), - expect.objectContaining({ method: 'DELETE' }) - ); - }); - - // ========================================== - // Pin / Star - // ========================================== - - it('feed.pin should POST pin endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { feedId: 'feed_1', pinned: true, pinnedAt: '2026-01-01T00:00:00Z' } - }); - - const result = await client.feed.pin('account', 'rec_123', 'feed_1'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/pin', - expect.objectContaining({ method: 'POST' }) - ); - expect(result.pinned).toBe(true); - }); - - it('feed.unpin should DELETE pin endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { feedId: 'feed_1', pinned: false } - }); - - const result = await client.feed.unpin('account', 'rec_123', 'feed_1'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/pin', - expect.objectContaining({ method: 'DELETE' }) - ); - expect(result.pinned).toBe(false); - }); - - it('feed.star should POST star endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { feedId: 'feed_1', starred: true, starredAt: '2026-01-01T00:00:00Z' } - }); - - const result = await client.feed.star('account', 'rec_123', 'feed_1'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/star', - expect.objectContaining({ method: 'POST' }) - ); - expect(result.starred).toBe(true); - }); - - it('feed.unstar should DELETE star endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { feedId: 'feed_1', starred: false } - }); - - const result = await client.feed.unstar('account', 'rec_123', 'feed_1'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/star', - expect.objectContaining({ method: 'DELETE' }) - ); - expect(result.starred).toBe(false); - }); - - // ========================================== - // Search & Changelog - // ========================================== - - it('feed.search should GET search endpoint with query params', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { items: [], total: 0, hasMore: false } - }); - - await client.feed.search('account', 'rec_123', 'follow up', { limit: 10 }); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/api/v1/data/account/rec_123/feed/search?query=follow+up'), - expect.any(Object) - ); - }); - - it('feed.getChangelog should GET changelog endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { entries: [], total: 0, hasMore: false } - }); - - await client.feed.getChangelog('account', 'rec_123', { field: 'status' }); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/changelog?field=status', - expect.any(Object) - ); - }); - - // ========================================== - // Subscriptions - // ========================================== - - it('feed.subscribe should POST subscribe endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { object: 'account', recordId: 'rec_123', events: ['all'], channels: ['in_app'] } - }); - - const result = await client.feed.subscribe('account', 'rec_123', { - events: ['comment', 'field_change'], - channels: ['in_app', 'email'] - }); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/subscribe', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - events: ['comment', 'field_change'], - channels: ['in_app', 'email'] - }) - }) - ); - expect(result.object).toBe('account'); - }); - - it('feed.unsubscribe should DELETE subscribe endpoint', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { object: 'account', recordId: 'rec_123', unsubscribed: true } - }); - - const result = await client.feed.unsubscribe('account', 'rec_123'); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/data/account/rec_123/subscribe', - expect.objectContaining({ method: 'DELETE' }) - ); - expect(result.unsubscribed).toBe(true); - }); -}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index db88be760a..5af5fd223b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -66,20 +66,6 @@ import { GetTranslationsResponse, GetFieldLabelsResponse, RegisterRequest, - GetFeedResponse, - CreateFeedItemResponse, - UpdateFeedItemResponse, - DeleteFeedItemResponse, - AddReactionResponse, - RemoveReactionResponse, - PinFeedItemResponse, - UnpinFeedItemResponse, - StarFeedItemResponse, - UnstarFeedItemResponse, - SearchFeedResponse, - GetChangelogResponse, - SubscribeResponse, - UnsubscribeResponse, WellKnownCapabilities, ApiRoutes, ImportRequest, @@ -2767,188 +2753,6 @@ export class ObjectStackClient { } }; - /** - * Feed / Chatter Services - * - * Provides access to the activity timeline (comments, field changes, tasks), - * emoji reactions, pin/star, search, changelog, and record subscriptions. - * Base path: /api/data/{object}/{recordId}/feed - */ - feed = { - /** - * List feed items for a record - */ - list: async (object: string, recordId: string, options?: { type?: string; limit?: number; cursor?: string }): Promise => { - const route = this.getRoute('data'); - const params = new URLSearchParams(); - if (options?.type) params.set('type', options.type); - if (options?.limit) params.set('limit', String(options.limit)); - if (options?.cursor) params.set('cursor', options.cursor); - const qs = params.toString(); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed${qs ? `?${qs}` : ''}`); - return this.unwrapResponse(res); - }, - - /** - * Create a new feed item (comment, note, task, etc.) - */ - create: async (object: string, recordId: string, data: { type: string; body?: string; mentions?: any[]; parentId?: string; visibility?: string }): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed`, { - method: 'POST', - body: JSON.stringify(data) - }); - return this.unwrapResponse(res); - }, - - /** - * Update an existing feed item - */ - update: async (object: string, recordId: string, feedId: string, data: { body?: string; mentions?: any[]; visibility?: string }): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}`, { - method: 'PUT', - body: JSON.stringify(data) - }); - return this.unwrapResponse(res); - }, - - /** - * Delete a feed item - */ - delete: async (object: string, recordId: string, feedId: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}`, { - method: 'DELETE' - }); - return this.unwrapResponse(res); - }, - - /** - * Add an emoji reaction to a feed item - */ - addReaction: async (object: string, recordId: string, feedId: string, emoji: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/reactions`, { - method: 'POST', - body: JSON.stringify({ emoji }) - }); - return this.unwrapResponse(res); - }, - - /** - * Remove an emoji reaction from a feed item - */ - removeReaction: async (object: string, recordId: string, feedId: string, emoji: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/reactions/${encodeURIComponent(emoji)}`, { - method: 'DELETE' - }); - return this.unwrapResponse(res); - }, - - /** - * Pin a feed item to the top of the timeline - */ - pin: async (object: string, recordId: string, feedId: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/pin`, { - method: 'POST' - }); - return this.unwrapResponse(res); - }, - - /** - * Unpin a feed item - */ - unpin: async (object: string, recordId: string, feedId: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/pin`, { - method: 'DELETE' - }); - return this.unwrapResponse(res); - }, - - /** - * Star (bookmark) a feed item - */ - star: async (object: string, recordId: string, feedId: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/star`, { - method: 'POST' - }); - return this.unwrapResponse(res); - }, - - /** - * Unstar a feed item - */ - unstar: async (object: string, recordId: string, feedId: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/star`, { - method: 'DELETE' - }); - return this.unwrapResponse(res); - }, - - /** - * Search feed items - */ - search: async (object: string, recordId: string, query: string, options?: { type?: string; actorId?: string; dateFrom?: string; dateTo?: string; limit?: number; cursor?: string }): Promise => { - const route = this.getRoute('data'); - const params = new URLSearchParams(); - params.set('query', query); - if (options?.type) params.set('type', options.type); - if (options?.actorId) params.set('actorId', options.actorId); - if (options?.dateFrom) params.set('dateFrom', options.dateFrom); - if (options?.dateTo) params.set('dateTo', options.dateTo); - if (options?.limit) params.set('limit', String(options.limit)); - if (options?.cursor) params.set('cursor', options.cursor); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/search?${params.toString()}`); - return this.unwrapResponse(res); - }, - - /** - * Get field-level changelog for a record - */ - getChangelog: async (object: string, recordId: string, options?: { field?: string; actorId?: string; dateFrom?: string; dateTo?: string; limit?: number; cursor?: string }): Promise => { - const route = this.getRoute('data'); - const params = new URLSearchParams(); - if (options?.field) params.set('field', options.field); - if (options?.actorId) params.set('actorId', options.actorId); - if (options?.dateFrom) params.set('dateFrom', options.dateFrom); - if (options?.dateTo) params.set('dateTo', options.dateTo); - if (options?.limit) params.set('limit', String(options.limit)); - if (options?.cursor) params.set('cursor', options.cursor); - const qs = params.toString(); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/changelog${qs ? `?${qs}` : ''}`); - return this.unwrapResponse(res); - }, - - /** - * Subscribe to record notifications - */ - subscribe: async (object: string, recordId: string, options?: { events?: string[]; channels?: string[] }): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/subscribe`, { - method: 'POST', - body: JSON.stringify(options || {}) - }); - return this.unwrapResponse(res); - }, - - /** - * Unsubscribe from record notifications - */ - unsubscribe: async (object: string, recordId: string): Promise => { - const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/subscribe`, { - method: 'DELETE' - }); - return this.unwrapResponse(res); - }, - }; - /** * Data Operations */ @@ -3398,6 +3202,10 @@ export class ObjectStackClient { notifications: '/api/v1/notifications', ai: '/api/v1/ai', i18n: '/api/v1/i18n', + // NOTE: the `feed` route constant is retained only to satisfy the + // discovery `ApiRoutes` type (`routes.feed`), which is a separate + // follow-up cleanup. The feed SDK accessor + backend were retired + // (ADR-0052 §5); no client code consumes this route. feed: '/api/v1/feed', graphql: '/graphql', }; @@ -3796,20 +3604,6 @@ export type { GetFieldLabelsResponse, RegisterRequest, RefreshTokenRequest, - GetFeedResponse, - CreateFeedItemResponse, - UpdateFeedItemResponse, - DeleteFeedItemResponse, - AddReactionResponse, - RemoveReactionResponse, - PinFeedItemResponse, - UnpinFeedItemResponse, - StarFeedItemResponse, - UnstarFeedItemResponse, - SearchFeedResponse, - GetChangelogResponse, - SubscribeResponse, - UnsubscribeResponse, WellKnownCapabilities, GetAuthConfigResponse, AuthProviderInfo, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index aa92cbc714..82564626a5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16,7 +16,6 @@ import type { } from '@objectstack/spec/api'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import { readServiceSelfInfo } from '@objectstack/spec/api'; -import type { IFeedService } from '@objectstack/spec/contracts'; import { parseFilterAST, isFilterAST } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; @@ -836,7 +835,6 @@ export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void export class ObjectStackProtocolImplementation implements ObjectStackProtocol { private engine: MetadataHostEngine; private getServicesRegistry?: () => Map; - private getFeedService?: () => IFeedService | undefined; /** * Project scope applied to sys_metadata reads/writes. When undefined * (single-kernel deployments), rows land in / come from the @@ -892,12 +890,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { constructor( engine: IDataEngine, getServicesRegistry?: () => Map, - getFeedService?: () => IFeedService | undefined, environmentId?: string, ) { this.engine = engine as MetadataHostEngine; this.getServicesRegistry = getServicesRegistry; - this.getFeedService = getFeedService; this.environmentId = environmentId; } @@ -1176,14 +1172,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return this.environmentId; } - private requireFeedService(): IFeedService { - const svc = this.getFeedService?.(); - if (!svc) { - throw new Error('Feed service not available. Install and register service-feed to enable feed operations.'); - } - return svc; - } - async getDiscovery() { // Get registered services from kernel if available const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); @@ -6313,157 +6301,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return { references: out }; } - // ========================================== - // Feed Operations - // ========================================== - - async listFeed(request: any): Promise { - const svc = this.requireFeedService(); - const result = await svc.listFeed({ - object: request.object, - recordId: request.recordId, - filter: request.type, - limit: request.limit, - cursor: request.cursor, - }); - return { success: true, data: result }; - } - - async createFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - const item = await svc.createFeedItem({ - object: request.object, - recordId: request.recordId, - type: request.type, - actor: { type: 'user', id: 'current_user' }, - body: request.body, - mentions: request.mentions, - parentId: request.parentId, - visibility: request.visibility, - }); - return { success: true, data: item }; - } - - async updateFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - const item = await svc.updateFeedItem(request.feedId, { - body: request.body, - mentions: request.mentions, - visibility: request.visibility, - }); - return { success: true, data: item }; - } - - async deleteFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - await svc.deleteFeedItem(request.feedId); - return { success: true, data: { feedId: request.feedId } }; - } - - async addReaction(request: any): Promise { - const svc = this.requireFeedService(); - const reactions = await svc.addReaction(request.feedId, request.emoji, 'current_user'); - return { success: true, data: { reactions } }; - } - - async removeReaction(request: any): Promise { - const svc = this.requireFeedService(); - const reactions = await svc.removeReaction(request.feedId, request.emoji, 'current_user'); - return { success: true, data: { reactions } }; - } - - async pinFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - const item = await svc.getFeedItem(request.feedId); - if (!item) throw new Error(`Feed item ${request.feedId} not found`); - // IFeedService doesn't have dedicated pin/unpin — use updateFeedItem to persist pin state - await svc.updateFeedItem(request.feedId, { visibility: item.visibility }); - return { success: true, data: { feedId: request.feedId, pinned: true, pinnedAt: new Date().toISOString() } }; - } - - async unpinFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - const item = await svc.getFeedItem(request.feedId); - if (!item) throw new Error(`Feed item ${request.feedId} not found`); - await svc.updateFeedItem(request.feedId, { visibility: item.visibility }); - return { success: true, data: { feedId: request.feedId, pinned: false } }; - } - - async starFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - const item = await svc.getFeedItem(request.feedId); - if (!item) throw new Error(`Feed item ${request.feedId} not found`); - // IFeedService doesn't have dedicated star/unstar — verify item exists then return state - await svc.updateFeedItem(request.feedId, { visibility: item.visibility }); - return { success: true, data: { feedId: request.feedId, starred: true, starredAt: new Date().toISOString() } }; - } - - async unstarFeedItem(request: any): Promise { - const svc = this.requireFeedService(); - const item = await svc.getFeedItem(request.feedId); - if (!item) throw new Error(`Feed item ${request.feedId} not found`); - await svc.updateFeedItem(request.feedId, { visibility: item.visibility }); - return { success: true, data: { feedId: request.feedId, starred: false } }; - } - - async searchFeed(request: any): Promise { - const svc = this.requireFeedService(); - // Search delegates to listFeed with filter since IFeedService doesn't have a dedicated search - const result = await svc.listFeed({ - object: request.object, - recordId: request.recordId, - filter: request.type, - limit: request.limit, - cursor: request.cursor, - }); - // Filter by query text in body - const queryLower = (request.query || '').toLowerCase(); - const filtered = result.items.filter((item: any) => - item.body?.toLowerCase().includes(queryLower) - ); - return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } }; - } - - async getChangelog(request: any): Promise { - const svc = this.requireFeedService(); - // Changelog retrieves field_change type feed items - const result = await svc.listFeed({ - object: request.object, - recordId: request.recordId, - filter: 'changes_only', - limit: request.limit, - cursor: request.cursor, - }); - const entries = result.items.map((item: any) => ({ - id: item.id, - object: item.object, - recordId: item.recordId, - actor: item.actor, - changes: item.changes || [], - timestamp: item.createdAt, - source: item.source, - })); - return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } }; - } - - async feedSubscribe(request: any): Promise { - const svc = this.requireFeedService(); - const subscription = await svc.subscribe({ - object: request.object, - recordId: request.recordId, - userId: 'current_user', - events: request.events, - channels: request.channels, - }); - return { success: true, data: subscription }; - } - - async feedUnsubscribe(request: any): Promise { - const svc = this.requireFeedService(); - const unsubscribed = await svc.unsubscribe(request.object, request.recordId, 'current_user'); - return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } }; - } - /** * Install a package from a manifest — the single canonical write primitive * for the package subsystem (ADR-0033 consolidation). diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 66cdc3ee11..2ebf46405b 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -82,7 +82,6 @@ function makeProtocol(opts: { environmentId?: string } = {}) { const protocol = new ObjectStackProtocolImplementation( mockEngine, undefined, // getServicesRegistry - undefined, // getFeedService opts.environmentId, ); return { protocol, mockEngine, registry }; diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 6d6235bbc0..e66abeb14f 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -230,7 +230,6 @@ export class ObjectQLPlugin implements Plugin { const protocolShim = new ObjectStackProtocolImplementation( this.ql, () => ctx.getServices ? ctx.getServices() : new Map(), - undefined, this.environmentId, ); diff --git a/packages/objectql/src/protocol-feed.test.ts b/packages/objectql/src/protocol-feed.test.ts deleted file mode 100644 index e24bd2f27c..0000000000 --- a/packages/objectql/src/protocol-feed.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { ObjectQL } from './engine.js'; -import type { IFeedService } from '@objectstack/spec/contracts'; - -/** - * Mock IFeedService for testing feed route handlers. - */ -function createMockFeedService(): IFeedService { - return { - listFeed: vi.fn().mockResolvedValue({ - items: [{ id: 'feed_1', type: 'comment', body: 'Hello world', createdAt: '2026-01-01T00:00:00Z' }], - total: 1, - hasMore: false, - }), - createFeedItem: vi.fn().mockResolvedValue({ - id: 'feed_new', - type: 'comment', - body: 'New comment', - createdAt: '2026-01-01T00:00:00Z', - }), - updateFeedItem: vi.fn().mockResolvedValue({ - id: 'feed_1', - type: 'comment', - body: 'Updated comment', - createdAt: '2026-01-01T00:00:00Z', - }), - deleteFeedItem: vi.fn().mockResolvedValue(undefined), - getFeedItem: vi.fn().mockResolvedValue({ - id: 'feed_1', - type: 'comment', - body: 'Hello world', - createdAt: '2026-01-01T00:00:00Z', - }), - addReaction: vi.fn().mockResolvedValue([ - { emoji: '👍', users: ['current_user'], count: 1 }, - ]), - removeReaction: vi.fn().mockResolvedValue([]), - subscribe: vi.fn().mockResolvedValue({ - id: 'sub_1', - object: 'account', - recordId: 'rec_123', - userId: 'current_user', - events: ['all'], - channels: ['in_app'], - }), - unsubscribe: vi.fn().mockResolvedValue(true), - getSubscription: vi.fn().mockResolvedValue(null), - }; -} - -describe('ObjectStackProtocolImplementation - Feed Operations', () => { - let protocol: ObjectStackProtocolImplementation; - let engine: ObjectQL; - let feedService: IFeedService; - - beforeEach(() => { - engine = new ObjectQL(); - feedService = createMockFeedService(); - protocol = new ObjectStackProtocolImplementation(engine, undefined, () => feedService); - }); - - // ========================================== - // Discovery - // ========================================== - - it('should show feed service as unavailable when not registered', async () => { - const protocolNoFeed = new ObjectStackProtocolImplementation(engine); - const discovery = await protocolNoFeed.getDiscovery(); - - expect(discovery.services.feed).toBeDefined(); - expect(discovery.services.feed.enabled).toBe(false); - expect(discovery.services.feed.status).toBe('unavailable'); - }); - - it('should show feed service as available when registered', async () => { - const mockServices = new Map(); - mockServices.set('feed', {}); - const protocolWithFeed = new ObjectStackProtocolImplementation(engine, () => mockServices, () => feedService); - const discovery = await protocolWithFeed.getDiscovery(); - - expect(discovery.services.feed).toBeDefined(); - expect(discovery.services.feed.enabled).toBe(true); - expect(discovery.services.feed.status).toBe('available'); - }); - - // ========================================== - // Feed CRUD - // ========================================== - - it('listFeed should delegate to feedService.listFeed', async () => { - const result = await protocol.listFeed({ object: 'account', recordId: 'rec_123' }); - - expect(result.success).toBe(true); - expect(result.data.items).toHaveLength(1); - expect(feedService.listFeed).toHaveBeenCalledWith( - expect.objectContaining({ object: 'account', recordId: 'rec_123' }) - ); - }); - - it('createFeedItem should delegate to feedService.createFeedItem', async () => { - const result = await protocol.createFeedItem({ - object: 'account', - recordId: 'rec_123', - type: 'comment', - body: 'New comment', - }); - - expect(result.success).toBe(true); - expect(result.data.id).toBe('feed_new'); - expect(feedService.createFeedItem).toHaveBeenCalledWith( - expect.objectContaining({ object: 'account', recordId: 'rec_123', type: 'comment', body: 'New comment' }) - ); - }); - - it('updateFeedItem should delegate to feedService.updateFeedItem', async () => { - const result = await protocol.updateFeedItem({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - body: 'Updated', - }); - - expect(result.success).toBe(true); - expect(result.data.body).toBe('Updated comment'); - expect(feedService.updateFeedItem).toHaveBeenCalledWith('feed_1', expect.objectContaining({ body: 'Updated' })); - }); - - it('deleteFeedItem should delegate to feedService.deleteFeedItem', async () => { - const result = await protocol.deleteFeedItem({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - }); - - expect(result.success).toBe(true); - expect(result.data.feedId).toBe('feed_1'); - expect(feedService.deleteFeedItem).toHaveBeenCalledWith('feed_1'); - }); - - // ========================================== - // Reactions - // ========================================== - - it('addReaction should delegate to feedService.addReaction', async () => { - const result = await protocol.addReaction({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - emoji: '👍', - }); - - expect(result.success).toBe(true); - expect(result.data.reactions).toHaveLength(1); - expect(feedService.addReaction).toHaveBeenCalledWith('feed_1', '👍', 'current_user'); - }); - - it('removeReaction should delegate to feedService.removeReaction', async () => { - const result = await protocol.removeReaction({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - emoji: '👍', - }); - - expect(result.success).toBe(true); - expect(result.data.reactions).toHaveLength(0); - expect(feedService.removeReaction).toHaveBeenCalledWith('feed_1', '👍', 'current_user'); - }); - - // ========================================== - // Pin / Star - // ========================================== - - it('pinFeedItem should verify item exists and return pinned status', async () => { - const result = await protocol.pinFeedItem({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - }); - - expect(result.success).toBe(true); - expect(result.data.feedId).toBe('feed_1'); - expect(result.data.pinned).toBe(true); - expect(result.data.pinnedAt).toBeDefined(); - }); - - it('unpinFeedItem should verify item exists and return unpinned status', async () => { - const result = await protocol.unpinFeedItem({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - }); - - expect(result.success).toBe(true); - expect(result.data.feedId).toBe('feed_1'); - expect(result.data.pinned).toBe(false); - }); - - it('starFeedItem should verify item exists and return starred status', async () => { - const result = await protocol.starFeedItem({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - }); - - expect(result.success).toBe(true); - expect(result.data.feedId).toBe('feed_1'); - expect(result.data.starred).toBe(true); - expect(result.data.starredAt).toBeDefined(); - }); - - it('unstarFeedItem should verify item exists and return unstarred status', async () => { - const result = await protocol.unstarFeedItem({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_1', - }); - - expect(result.success).toBe(true); - expect(result.data.feedId).toBe('feed_1'); - expect(result.data.starred).toBe(false); - }); - - // ========================================== - // Search & Changelog - // ========================================== - - it('searchFeed should filter items by query text', async () => { - const result = await protocol.searchFeed({ - object: 'account', - recordId: 'rec_123', - query: 'hello', - }); - - expect(result.success).toBe(true); - expect(result.data.items).toHaveLength(1); - expect(result.data.hasMore).toBe(false); - }); - - it('getChangelog should return field change entries', async () => { - const result = await protocol.getChangelog({ - object: 'account', - recordId: 'rec_123', - }); - - expect(result.success).toBe(true); - expect(result.data.entries).toBeDefined(); - expect(feedService.listFeed).toHaveBeenCalledWith( - expect.objectContaining({ filter: 'changes_only' }) - ); - }); - - // ========================================== - // Subscriptions - // ========================================== - - it('feedSubscribe should delegate to feedService.subscribe', async () => { - const result = await protocol.feedSubscribe({ - object: 'account', - recordId: 'rec_123', - events: ['all'], - channels: ['in_app'], - }); - - expect(result.success).toBe(true); - expect(result.data.object).toBe('account'); - expect(feedService.subscribe).toHaveBeenCalledWith( - expect.objectContaining({ object: 'account', recordId: 'rec_123' }) - ); - }); - - it('feedUnsubscribe should delegate to feedService.unsubscribe', async () => { - const result = await protocol.feedUnsubscribe({ - object: 'account', - recordId: 'rec_123', - }); - - expect(result.success).toBe(true); - expect(result.data.unsubscribed).toBe(true); - expect(feedService.unsubscribe).toHaveBeenCalledWith('account', 'rec_123', 'current_user'); - }); - - // ========================================== - // Error handling - // ========================================== - - it('should throw when feed service is not available', async () => { - const protocolNoFeed = new ObjectStackProtocolImplementation(engine); - - await expect(protocolNoFeed.listFeed({ object: 'a', recordId: 'b' })) - .rejects.toThrow('Feed service not available'); - }); - - it('pinFeedItem should throw when feed item not found', async () => { - (feedService.getFeedItem as any).mockResolvedValue(null); - - await expect(protocol.pinFeedItem({ object: 'a', recordId: 'b', feedId: 'nonexistent' })) - .rejects.toThrow('Feed item nonexistent not found'); - }); -}); diff --git a/packages/objectql/src/protocol-lock-enforcement.test.ts b/packages/objectql/src/protocol-lock-enforcement.test.ts index 91405785fb..1ce0733c76 100644 --- a/packages/objectql/src/protocol-lock-enforcement.test.ts +++ b/packages/objectql/src/protocol-lock-enforcement.test.ts @@ -40,7 +40,6 @@ function makeProtocol(opts: { environmentId?: string } = {}) { const protocol = new ObjectStackProtocolImplementation( mockEngine, undefined, - undefined, opts.environmentId ?? 'env_prod', ); return { protocol, mockEngine, registry }; @@ -173,7 +172,7 @@ describe('ADR-0010 L3 lock enforcement — single-kernel bypass', () => { }; // No environmentId — single-kernel / control plane. const protocol = new ObjectStackProtocolImplementation( - mockEngine, undefined, undefined, undefined, + mockEngine, undefined, undefined, ); seedLockedArtifact(registry, 'view', 'case_grid', 'full'); diff --git a/packages/objectql/src/protocol-meta-types-rich.test.ts b/packages/objectql/src/protocol-meta-types-rich.test.ts index dfa3aa1cea..a493038972 100644 --- a/packages/objectql/src/protocol-meta-types-rich.test.ts +++ b/packages/objectql/src/protocol-meta-types-rich.test.ts @@ -113,7 +113,7 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () => it('saveMetaItem honours the env-elevated allow list', async () => { // Scoped (project) protocol — overlay gate applies. - const scoped = new ObjectStackProtocolImplementation(mockEngine, undefined, undefined, 'env_alpha'); + const scoped = new ObjectStackProtocolImplementation(mockEngine, undefined, 'env_alpha'); mockEngine.findOne.mockResolvedValue(null); // Without env var: `agent` writes blocked — the one remaining diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index c2ba396fbe..26c11e0a42 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1165,7 +1165,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { scoped = new ObjectStackProtocolImplementation( mockEngine, undefined, - undefined, 'env_alpha', ); }); diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index c761a4f584..8fec457e30 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -262,7 +262,7 @@ describe('registry shadow — scoped-kernel lock enforcement is shadow-immune', delete: async () => ({ deleted: 1 }), }; const protocol = new ObjectStackProtocolImplementation( - mockEngine, undefined, undefined, 'env_prod', + mockEngine, undefined, 'env_prod', ); await expect(protocol.saveMetaItem({ diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 8dcf44a72a..46e61d9d01 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -299,16 +299,9 @@ "ExternalTableSchema (const)", "FIELD_GROUP_SYSTEM_FIELDS (const)", "FILTER_OPERATORS (const)", - "FeedActor (type)", - "FeedActorSchema (const)", "FeedFilterMode (type)", - "FeedItem (type)", - "FeedItemSchema (const)", "FeedItemType (type)", - "FeedVisibility (type)", "Field (type)", - "FieldChangeEntry (type)", - "FieldChangeEntrySchema (const)", "FieldGroupCollapse (type)", "FieldGroupSection (interface)", "FieldInput (type)", @@ -363,8 +356,6 @@ "Mapping (type)", "MappingInput (type)", "MappingSchema (const)", - "Mention (type)", - "MentionSchema (const)", "Metric (type)", "MetricSchema (const)", "NoSQLDataTypeMapping (type)", @@ -385,7 +376,6 @@ "NoSQLTransactionOptionsSchema (const)", "NormalizedFilter (type)", "NormalizedFilterSchema (const)", - "NotificationChannel (type)", "ObjectAccessConfig (type)", "ObjectAccessConfigSchema (const)", "ObjectCapabilities (type)", @@ -421,13 +411,9 @@ "QuerySchema (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", "RangeOperatorSchema (const)", - "Reaction (type)", - "ReactionSchema (const)", "RecordFlow (type)", "RecordFlowContainer (type)", "RecordFlowSurface (interface)", - "RecordSubscription (type)", - "RecordSubscriptionSchema (const)", "RecordSurface (type)", "RecordSurfaceOptions (interface)", "RecordSurfaceViewport (type)", @@ -494,7 +480,6 @@ "StateMachineValidation (type)", "StateMachineValidationSchema (const)", "StringOperatorSchema (const)", - "SubscriptionEventType (type)", "TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES (const)", @@ -2131,10 +2116,6 @@ "./api": [ "AckMessage (type)", "AckMessageSchema (const)", - "AddReactionRequest (type)", - "AddReactionRequestSchema (const)", - "AddReactionResponse (type)", - "AddReactionResponseSchema (const)", "AiInsightsRequest (type)", "AiInsightsRequestSchema (const)", "AiInsightsResponse (type)", @@ -2264,8 +2245,6 @@ "CacheInvalidationTarget (type)", "Callback (type)", "CallbackSchema (const)", - "ChangelogEntry (type)", - "ChangelogEntrySchema (const)", "CheckPermissionRequest (type)", "CheckPermissionRequestSchema (const)", "CheckPermissionResponse (type)", @@ -2289,10 +2268,6 @@ "CreateExportJobRequestSchema (const)", "CreateExportJobResponse (type)", "CreateExportJobResponseSchema (const)", - "CreateFeedItemRequest (type)", - "CreateFeedItemRequestSchema (const)", - "CreateFeedItemResponse (type)", - "CreateFeedItemResponseSchema (const)", "CreateFlowRequest (type)", "CreateFlowRequestSchema (const)", "CreateFlowResponse (type)", @@ -2347,10 +2322,6 @@ "DeleteDataRequestSchema (const)", "DeleteDataResponse (type)", "DeleteDataResponseSchema (const)", - "DeleteFeedItemRequest (type)", - "DeleteFeedItemRequestSchema (const)", - "DeleteFeedItemResponse (type)", - "DeleteFeedItemResponseSchema (const)", "DeleteFlowRequest (type)", "DeleteFlowRequestSchema (const)", "DeleteFlowResponse (type)", @@ -2453,16 +2424,6 @@ "FederationProvidesSchema (const)", "FederationRequires (type)", "FederationRequiresSchema (const)", - "FeedApiContracts (const)", - "FeedApiErrorCode (type)", - "FeedItemPathParams (type)", - "FeedItemPathParamsSchema (const)", - "FeedListFilterType (const)", - "FeedPathParams (type)", - "FeedPathParamsSchema (const)", - "FeedProtocol (interface)", - "FeedUnsubscribeRequest (type)", - "FeedUnsubscribeRequestSchema (const)", "FieldError (type)", "FieldErrorSchema (const)", "FieldMappingEntry (type)", @@ -2485,10 +2446,6 @@ "GetAnalyticsMetaRequestSchema (const)", "GetAuthConfigResponse (type)", "GetAuthConfigResponseSchema (const)", - "GetChangelogRequest (type)", - "GetChangelogRequestSchema (const)", - "GetChangelogResponse (type)", - "GetChangelogResponseSchema (const)", "GetDataRequest (type)", "GetDataRequestSchema (const)", "GetDataResponse (type)", @@ -2505,10 +2462,6 @@ "GetExportJobDownloadRequestSchema (const)", "GetExportJobDownloadResponse (type)", "GetExportJobDownloadResponseSchema (const)", - "GetFeedRequest (type)", - "GetFeedRequestSchema (const)", - "GetFeedResponse (type)", - "GetFeedResponseSchema (const)", "GetFieldLabelsRequest (type)", "GetFieldLabelsRequestSchema (const)", "GetFieldLabelsResponse (type)", @@ -2829,10 +2782,6 @@ "PackageUpgradeResponse (type)", "PackageUpgradeResponseSchema (const)", "PermissionProtocol (interface)", - "PinFeedItemRequest (type)", - "PinFeedItemRequestSchema (const)", - "PinFeedItemResponse (type)", - "PinFeedItemResponseSchema (const)", "PingMessage (type)", "PingMessageSchema (const)", "PongMessage (type)", @@ -2888,10 +2837,6 @@ "RegisterDeviceResponseSchema (const)", "RegisterRequest (type)", "RegisterRequestSchema (const)", - "RemoveReactionRequest (type)", - "RemoveReactionRequestSchema (const)", - "RemoveReactionResponse (type)", - "RemoveReactionResponseSchema (const)", "RequestValidationConfig (type)", "RequestValidationConfigInput (type)", "RequestValidationConfigSchema (const)", @@ -2949,10 +2894,6 @@ "ScheduledExport (type)", "ScheduledExportSchema (const)", "SchemaDefinition (type)", - "SearchFeedRequest (type)", - "SearchFeedRequestSchema (const)", - "SearchFeedResponse (type)", - "SearchFeedResponseSchema (const)", "ServiceInfo (type)", "ServiceInfoSchema (const)", "ServiceSelfInfo (type)", @@ -2977,20 +2918,12 @@ "SingleRecordResponseSchema (const)", "StandardApiContracts (const)", "StandardErrorCode (type)", - "StarFeedItemRequest (type)", - "StarFeedItemRequestSchema (const)", - "StarFeedItemResponse (type)", - "StarFeedItemResponseSchema (const)", "StorageApiContracts (const)", "SubgraphConfig (type)", "SubgraphConfigInput (type)", "SubgraphConfigSchema (const)", "SubscribeMessage (type)", "SubscribeMessageSchema (const)", - "SubscribeRequest (type)", - "SubscribeRequestSchema (const)", - "SubscribeResponse (type)", - "SubscribeResponseSchema (const)", "Subscription (type)", "SubscriptionEventSchema (const)", "SubscriptionSchema (const)", @@ -3013,32 +2946,18 @@ "UninstallPackageRequestSchema (const)", "UninstallPackageResponse (type)", "UninstallPackageResponseSchema (const)", - "UnpinFeedItemRequest (type)", - "UnpinFeedItemRequestSchema (const)", - "UnpinFeedItemResponse (type)", - "UnpinFeedItemResponseSchema (const)", "UnregisterDeviceRequest (type)", "UnregisterDeviceRequestSchema (const)", "UnregisterDeviceResponse (type)", "UnregisterDeviceResponseSchema (const)", - "UnstarFeedItemRequest (type)", - "UnstarFeedItemRequestSchema (const)", - "UnstarFeedItemResponse (type)", - "UnstarFeedItemResponseSchema (const)", "UnsubscribeMessage (type)", "UnsubscribeMessageSchema (const)", "UnsubscribeRequest (type)", "UnsubscribeRequestSchema (const)", - "UnsubscribeResponse (type)", - "UnsubscribeResponseSchema (const)", "UpdateDataRequest (type)", "UpdateDataRequestSchema (const)", "UpdateDataResponse (type)", "UpdateDataResponseSchema (const)", - "UpdateFeedItemRequest (type)", - "UpdateFeedItemRequestSchema (const)", - "UpdateFeedItemResponse (type)", - "UpdateFeedItemResponseSchema (const)", "UpdateFlowRequest (type)", "UpdateFlowRequestSchema (const)", "UpdateFlowResponse (type)", @@ -3564,7 +3483,6 @@ "CounterIncrOptions (interface)", "CreateExportJobInput (interface)", "CreateExportJobResult (interface)", - "CreateFeedItemInput (interface)", "CreateShareLinkInput (interface)", "CryptoContext (interface)", "CryptoHandle (interface)", @@ -3581,7 +3499,6 @@ "ExecuteUpgradeInput (interface)", "ExportJobDownload (interface)", "ExportJobListResult (interface)", - "FeedListResult (interface)", "FinishReason (type)", "GenerateDraftOpts (interface)", "GenerateObjectOptions (interface)", @@ -3613,7 +3530,6 @@ "IEmbedder (interface)", "IExportService (interface)", "IExternalDatasourceService (interface)", - "IFeedService (interface)", "IGraphQLService (interface)", "IHierarchyScopeResolver (interface)", "IHttpRequest (interface)", @@ -3670,7 +3586,6 @@ "KnowledgeSearchOptions (interface)", "LLMAdapter (interface)", "ListExportJobsOptions (interface)", - "ListFeedOptions (interface)", "ListShareLinksFilter (interface)", "LockAcquireOptions (interface)", "LockHandle (interface)", @@ -3758,7 +3673,6 @@ "StorageFileInfo (interface)", "StorageUploadOptions (interface)", "StrategyContext (interface)", - "SubscribeInput (interface)", "SubscribeOptions (interface)", "SystemModelMessage (type)", "TextStreamPart (type)", @@ -3769,7 +3683,6 @@ "ToolSet (type)", "TransportSendResult (interface)", "Unsubscribe (type)", - "UpdateFeedItemInput (interface)", "UploadArtifactInput (interface)", "UploadArtifactResult (interface)", "UserModelMessage (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index aad0c068b9..17685685b4 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -69,8 +69,6 @@ "ai/VectorStore", "ai/VectorStoreProvider", "api/AckMessage", - "api/AddReactionRequest", - "api/AddReactionResponse", "api/AiInsightsRequest", "api/AiInsightsResponse", "api/AiNlqRequest", @@ -133,7 +131,6 @@ "api/CacheInvalidationResponse", "api/CacheInvalidationTarget", "api/Callback", - "api/ChangelogEntry", "api/CheckPermissionRequest", "api/CheckPermissionResponse", "api/CodeGenerationTemplate", @@ -146,8 +143,6 @@ "api/CreateDataResponse", "api/CreateExportJobRequest", "api/CreateExportJobResponse", - "api/CreateFeedItemRequest", - "api/CreateFeedItemResponse", "api/CreateFlowRequest", "api/CreateFlowResponse", "api/CreateImportJobRequest", @@ -168,8 +163,6 @@ "api/DeduplicationStrategy", "api/DeleteDataRequest", "api/DeleteDataResponse", - "api/DeleteFeedItemRequest", - "api/DeleteFeedItemResponse", "api/DeleteFlowRequest", "api/DeleteFlowResponse", "api/DeleteManyDataRequest", @@ -220,11 +213,6 @@ "api/FederationGateway", "api/FederationProvides", "api/FederationRequires", - "api/FeedApiErrorCode", - "api/FeedItemPathParams", - "api/FeedListFilterType", - "api/FeedPathParams", - "api/FeedUnsubscribeRequest", "api/FieldError", "api/FieldMappingEntry", "api/FileTypeValidation", @@ -237,8 +225,6 @@ "api/GeneratedEndpoint", "api/GetAnalyticsMetaRequest", "api/GetAuthConfigResponse", - "api/GetChangelogRequest", - "api/GetChangelogResponse", "api/GetDataRequest", "api/GetDataResponse", "api/GetDiscoveryRequest", @@ -247,8 +233,6 @@ "api/GetEffectivePermissionsResponse", "api/GetExportJobDownloadRequest", "api/GetExportJobDownloadResponse", - "api/GetFeedRequest", - "api/GetFeedResponse", "api/GetFieldLabelsRequest", "api/GetFieldLabelsResponse", "api/GetFlowRequest", @@ -401,8 +385,6 @@ "api/PackageRollbackResponse", "api/PackageUpgradeRequest", "api/PackageUpgradeResponse", - "api/PinFeedItemRequest", - "api/PinFeedItemResponse", "api/PingMessage", "api/PongMessage", "api/PresenceMessage", @@ -431,8 +413,6 @@ "api/RegisterDeviceRequest", "api/RegisterDeviceResponse", "api/RegisterRequest", - "api/RemoveReactionRequest", - "api/RemoveReactionResponse", "api/RequestValidationConfig", "api/ResolveDependenciesRequest", "api/ResolveDependenciesResponse", @@ -459,8 +439,6 @@ "api/ScheduleExportResponse", "api/ScheduledExport", "api/SchemaDefinition", - "api/SearchFeedRequest", - "api/SearchFeedResponse", "api/ServiceInfo", "api/ServiceSelfInfo", "api/ServiceStatus", @@ -473,12 +451,8 @@ "api/SimplePresenceState", "api/SingleRecordResponse", "api/StandardErrorCode", - "api/StarFeedItemRequest", - "api/StarFeedItemResponse", "api/SubgraphConfig", "api/SubscribeMessage", - "api/SubscribeRequest", - "api/SubscribeResponse", "api/Subscription", "api/SubscriptionEvent", "api/ToggleFlowRequest", @@ -491,19 +465,12 @@ "api/UninstallPackageApiResponse", "api/UninstallPackageRequest", "api/UninstallPackageResponse", - "api/UnpinFeedItemRequest", - "api/UnpinFeedItemResponse", "api/UnregisterDeviceRequest", "api/UnregisterDeviceResponse", - "api/UnstarFeedItemRequest", - "api/UnstarFeedItemResponse", "api/UnsubscribeMessage", "api/UnsubscribeRequest", - "api/UnsubscribeResponse", "api/UpdateDataRequest", "api/UpdateDataResponse", - "api/UpdateFeedItemRequest", - "api/UpdateFeedItemResponse", "api/UpdateFlowRequest", "api/UpdateFlowResponse", "api/UpdateManyDataRequest", @@ -778,13 +745,9 @@ "data/ExternalFieldMapping", "data/ExternalLookup", "data/ExternalTable", - "data/FeedActor", "data/FeedFilterMode", - "data/FeedItem", "data/FeedItemType", - "data/FeedVisibility", "data/Field", - "data/FieldChangeEntry", "data/FieldMapping", "data/FieldNode", "data/FieldReference", @@ -807,7 +770,6 @@ "data/LifecycleClass", "data/LocationCoordinates", "data/Mapping", - "data/Mention", "data/Metric", "data/ModeSchema", "data/NoSQLDataTypeMapping", @@ -818,7 +780,6 @@ "data/NoSQLOperationType", "data/NoSQLQueryOptions", "data/NoSQLTransactionOptions", - "data/NotificationChannel", "data/Object", "data/ObjectAccessConfig", "data/ObjectCapabilities", @@ -833,8 +794,6 @@ "data/PoolConfig", "data/Query", "data/QueryFilter", - "data/Reaction", - "data/RecordSubscription", "data/ReferenceResolution", "data/ReferenceResolutionError", "data/ReplicationConfig", @@ -860,7 +819,6 @@ "data/SpecialOperator", "data/StateMachineValidation", "data/StringOperator", - "data/SubscriptionEventType", "data/TenancyConfig", "data/TimeUpdateInterval", "data/TransformType", diff --git a/packages/spec/src/api/feed-api.test.ts b/packages/spec/src/api/feed-api.test.ts deleted file mode 100644 index c174a0eb32..0000000000 --- a/packages/spec/src/api/feed-api.test.ts +++ /dev/null @@ -1,850 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - FeedPathParamsSchema, - FeedItemPathParamsSchema, - GetFeedRequestSchema, - GetFeedResponseSchema, - CreateFeedItemRequestSchema, - CreateFeedItemResponseSchema, - UpdateFeedItemRequestSchema, - UpdateFeedItemResponseSchema, - DeleteFeedItemRequestSchema, - DeleteFeedItemResponseSchema, - AddReactionRequestSchema, - AddReactionResponseSchema, - RemoveReactionRequestSchema, - RemoveReactionResponseSchema, - SubscribeRequestSchema, - SubscribeResponseSchema, - FeedUnsubscribeRequestSchema, - UnsubscribeResponseSchema, - FeedApiErrorCode, - FeedApiContracts, - PinFeedItemRequestSchema, - PinFeedItemResponseSchema, - StarFeedItemRequestSchema, - StarFeedItemResponseSchema, - SearchFeedRequestSchema, - SearchFeedResponseSchema, - GetChangelogRequestSchema, - ChangelogEntrySchema, - GetChangelogResponseSchema, -} from './feed-api.zod'; - -// ========================================== -// Path Parameters -// ========================================== - -describe('FeedPathParamsSchema', () => { - it('should accept valid path params', () => { - const params = FeedPathParamsSchema.parse({ - object: 'account', - recordId: 'rec_123', - }); - expect(params.object).toBe('account'); - expect(params.recordId).toBe('rec_123'); - }); - - it('should reject missing object', () => { - expect(() => FeedPathParamsSchema.parse({ recordId: 'rec_123' })).toThrow(); - }); - - it('should reject missing recordId', () => { - expect(() => FeedPathParamsSchema.parse({ object: 'account' })).toThrow(); - }); -}); - -describe('FeedItemPathParamsSchema', () => { - it('should accept valid item path params', () => { - const params = FeedItemPathParamsSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - }); - expect(params.feedId).toBe('feed_001'); - }); - - it('should reject missing feedId', () => { - expect(() => - FeedItemPathParamsSchema.parse({ object: 'account', recordId: 'rec_123' }) - ).toThrow(); - }); -}); - -// ========================================== -// Feed List (GET) -// ========================================== - -describe('GetFeedRequestSchema', () => { - it('should accept request with defaults', () => { - const req = GetFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - }); - expect(req.type).toBe('all'); - expect(req.limit).toBe(20); - expect(req.cursor).toBeUndefined(); - }); - - it('should accept request with all fields', () => { - const req = GetFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - type: 'comments_only', - limit: 50, - cursor: 'cursor_abc', - }); - expect(req.type).toBe('comments_only'); - expect(req.limit).toBe(50); - expect(req.cursor).toBe('cursor_abc'); - }); - - it('should reject limit exceeding max', () => { - expect(() => - GetFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - limit: 200, - }) - ).toThrow(); - }); - - it('should reject limit below min', () => { - expect(() => - GetFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - limit: 0, - }) - ).toThrow(); - }); - - it('should reject invalid filter type', () => { - expect(() => - GetFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - type: 'invalid_filter', - }) - ).toThrow(); - }); -}); - -describe('GetFeedResponseSchema', () => { - it('should accept valid response with items', () => { - const resp = GetFeedResponseSchema.parse({ - success: true, - data: { - items: [ - { - id: 'feed_001', - type: 'comment', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456', name: 'John Smith' }, - body: 'Great progress!', - createdAt: '2026-01-15T10:30:00Z', - }, - ], - hasMore: false, - }, - }); - expect(resp.data.items).toHaveLength(1); - expect(resp.data.items[0].type).toBe('comment'); - expect(resp.data.hasMore).toBe(false); - }); - - it('should accept response with pagination', () => { - const resp = GetFeedResponseSchema.parse({ - success: true, - data: { - items: [], - total: 42, - nextCursor: 'cursor_next', - hasMore: true, - }, - }); - expect(resp.data.total).toBe(42); - expect(resp.data.nextCursor).toBe('cursor_next'); - expect(resp.data.hasMore).toBe(true); - }); - - it('should reject missing hasMore', () => { - expect(() => - GetFeedResponseSchema.parse({ - success: true, - data: { items: [] }, - }) - ).toThrow(); - }); -}); - -// ========================================== -// Feed Create (POST) -// ========================================== - -describe('CreateFeedItemRequestSchema', () => { - it('should accept minimal comment request', () => { - const req = CreateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - type: 'comment', - body: 'Hello!', - }); - expect(req.type).toBe('comment'); - expect(req.body).toBe('Hello!'); - expect(req.visibility).toBe('public'); - }); - - it('should accept comment with mentions and visibility', () => { - const req = CreateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - type: 'comment', - body: 'Hey @jane', - mentions: [ - { type: 'user', id: 'user_789', name: 'Jane Doe', offset: 4, length: 5 }, - ], - visibility: 'internal', - }); - expect(req.mentions).toHaveLength(1); - expect(req.visibility).toBe('internal'); - }); - - it('should accept threaded reply', () => { - const req = CreateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - type: 'comment', - body: 'Reply text', - parentId: 'feed_001', - }); - expect(req.parentId).toBe('feed_001'); - }); - - it('should reject missing type', () => { - expect(() => - CreateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - body: 'Hello', - }) - ).toThrow(); - }); - - it('should reject invalid feed item type', () => { - expect(() => - CreateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - type: 'unknown_type', - }) - ).toThrow(); - }); -}); - -describe('CreateFeedItemResponseSchema', () => { - it('should accept valid creation response', () => { - const resp = CreateFeedItemResponseSchema.parse({ - success: true, - data: { - id: 'feed_002', - type: 'comment', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456', name: 'John' }, - body: 'New comment', - createdAt: '2026-01-15T11:00:00Z', - }, - }); - expect(resp.data.id).toBe('feed_002'); - }); -}); - -// ========================================== -// Feed Update (PUT) -// ========================================== - -describe('UpdateFeedItemRequestSchema', () => { - it('should accept body update', () => { - const req = UpdateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - body: 'Updated comment', - }); - expect(req.body).toBe('Updated comment'); - expect(req.feedId).toBe('feed_001'); - }); - - it('should accept visibility update', () => { - const req = UpdateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - visibility: 'private', - }); - expect(req.visibility).toBe('private'); - }); - - it('should reject missing feedId', () => { - expect(() => - UpdateFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - body: 'Updated', - }) - ).toThrow(); - }); -}); - -describe('UpdateFeedItemResponseSchema', () => { - it('should accept valid update response', () => { - const resp = UpdateFeedItemResponseSchema.parse({ - success: true, - data: { - id: 'feed_001', - type: 'comment', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456' }, - body: 'Updated comment', - createdAt: '2026-01-15T10:30:00Z', - editedAt: '2026-01-15T11:00:00Z', - isEdited: true, - }, - }); - expect(resp.data.isEdited).toBe(true); - expect(resp.data.editedAt).toBeDefined(); - }); -}); - -// ========================================== -// Feed Delete (DELETE) -// ========================================== - -describe('DeleteFeedItemRequestSchema', () => { - it('should accept valid delete params', () => { - const req = DeleteFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - }); - expect(req.feedId).toBe('feed_001'); - }); -}); - -describe('DeleteFeedItemResponseSchema', () => { - it('should accept valid delete response', () => { - const resp = DeleteFeedItemResponseSchema.parse({ - success: true, - data: { feedId: 'feed_001' }, - }); - expect(resp.data.feedId).toBe('feed_001'); - }); - - it('should reject missing feedId in response data', () => { - expect(() => - DeleteFeedItemResponseSchema.parse({ - success: true, - data: {}, - }) - ).toThrow(); - }); -}); - -// ========================================== -// Reactions -// ========================================== - -describe('AddReactionRequestSchema', () => { - it('should accept valid reaction', () => { - const req = AddReactionRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - emoji: '👍', - }); - expect(req.emoji).toBe('👍'); - }); - - it('should accept shortcode emoji', () => { - const req = AddReactionRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - emoji: ':thumbsup:', - }); - expect(req.emoji).toBe(':thumbsup:'); - }); - - it('should reject missing emoji', () => { - expect(() => - AddReactionRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - }) - ).toThrow(); - }); -}); - -describe('AddReactionResponseSchema', () => { - it('should accept valid reaction response', () => { - const resp = AddReactionResponseSchema.parse({ - success: true, - data: { - reactions: [ - { emoji: '👍', userIds: ['user_456'], count: 1 }, - ], - }, - }); - expect(resp.data.reactions).toHaveLength(1); - expect(resp.data.reactions[0].count).toBe(1); - }); -}); - -describe('RemoveReactionRequestSchema', () => { - it('should accept valid removal', () => { - const req = RemoveReactionRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - emoji: '👍', - }); - expect(req.emoji).toBe('👍'); - }); -}); - -describe('RemoveReactionResponseSchema', () => { - it('should accept valid removal response with empty reactions', () => { - const resp = RemoveReactionResponseSchema.parse({ - success: true, - data: { reactions: [] }, - }); - expect(resp.data.reactions).toHaveLength(0); - }); -}); - -// ========================================== -// Subscription -// ========================================== - -describe('SubscribeRequestSchema', () => { - it('should accept request with defaults', () => { - const req = SubscribeRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - }); - expect(req.events).toEqual(['all']); - expect(req.channels).toEqual(['in_app']); - }); - - it('should accept request with specific events and channels', () => { - const req = SubscribeRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - events: ['comment', 'field_change'], - channels: ['in_app', 'email'], - }); - expect(req.events).toEqual(['comment', 'field_change']); - expect(req.channels).toEqual(['in_app', 'email']); - }); - - it('should reject invalid event type', () => { - expect(() => - SubscribeRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - events: ['unknown_event'], - }) - ).toThrow(); - }); - - it('should reject invalid channel', () => { - expect(() => - SubscribeRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - channels: ['sms'], - }) - ).toThrow(); - }); -}); - -describe('SubscribeResponseSchema', () => { - it('should accept valid subscription response', () => { - const resp = SubscribeResponseSchema.parse({ - success: true, - data: { - object: 'account', - recordId: 'rec_123', - userId: 'user_456', - events: ['comment', 'field_change'], - channels: ['in_app', 'email'], - active: true, - createdAt: '2026-01-15T10:00:00Z', - }, - }); - expect(resp.data.userId).toBe('user_456'); - expect(resp.data.active).toBe(true); - }); -}); - -describe('FeedUnsubscribeRequestSchema', () => { - it('should accept valid unsubscribe params', () => { - const req = FeedUnsubscribeRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - }); - expect(req.object).toBe('account'); - expect(req.recordId).toBe('rec_123'); - }); -}); - -describe('UnsubscribeResponseSchema', () => { - it('should accept valid unsubscribe response', () => { - const resp = UnsubscribeResponseSchema.parse({ - success: true, - data: { - object: 'account', - recordId: 'rec_123', - unsubscribed: true, - }, - }); - expect(resp.data.unsubscribed).toBe(true); - }); - - it('should reject missing unsubscribed flag', () => { - expect(() => - UnsubscribeResponseSchema.parse({ - success: true, - data: { - object: 'account', - recordId: 'rec_123', - }, - }) - ).toThrow(); - }); -}); - -// ========================================== -// Error Codes -// ========================================== - -describe('FeedApiErrorCode', () => { - it('should accept valid error codes', () => { - expect(FeedApiErrorCode.parse('feed_item_not_found')).toBe('feed_item_not_found'); - expect(FeedApiErrorCode.parse('feed_permission_denied')).toBe('feed_permission_denied'); - expect(FeedApiErrorCode.parse('reaction_already_exists')).toBe('reaction_already_exists'); - }); - - it('should reject invalid error code', () => { - expect(() => FeedApiErrorCode.parse('unknown_error')).toThrow(); - }); -}); - -// ========================================== -// Contract Registry -// ========================================== - -describe('FeedApiContracts', () => { - it('should define all 14 endpoints', () => { - expect(Object.keys(FeedApiContracts)).toHaveLength(14); - }); - - it('should have correct HTTP methods', () => { - expect(FeedApiContracts.listFeed.method).toBe('GET'); - expect(FeedApiContracts.createFeedItem.method).toBe('POST'); - expect(FeedApiContracts.updateFeedItem.method).toBe('PUT'); - expect(FeedApiContracts.deleteFeedItem.method).toBe('DELETE'); - expect(FeedApiContracts.addReaction.method).toBe('POST'); - expect(FeedApiContracts.removeReaction.method).toBe('DELETE'); - expect(FeedApiContracts.pinFeedItem.method).toBe('POST'); - expect(FeedApiContracts.unpinFeedItem.method).toBe('DELETE'); - expect(FeedApiContracts.starFeedItem.method).toBe('POST'); - expect(FeedApiContracts.unstarFeedItem.method).toBe('DELETE'); - expect(FeedApiContracts.searchFeed.method).toBe('GET'); - expect(FeedApiContracts.getChangelog.method).toBe('GET'); - expect(FeedApiContracts.subscribe.method).toBe('POST'); - expect(FeedApiContracts.unsubscribe.method).toBe('DELETE'); - }); - - it('should have valid paths', () => { - expect(FeedApiContracts.listFeed.path).toContain('/feed'); - expect(FeedApiContracts.addReaction.path).toContain('/reactions'); - expect(FeedApiContracts.pinFeedItem.path).toContain('/pin'); - expect(FeedApiContracts.starFeedItem.path).toContain('/star'); - expect(FeedApiContracts.searchFeed.path).toContain('/feed/search'); - expect(FeedApiContracts.getChangelog.path).toContain('/changelog'); - expect(FeedApiContracts.subscribe.path).toContain('/subscribe'); - }); -}); - -// ========================================== -// Pin Feed Item -// ========================================== - -describe('PinFeedItemRequestSchema', () => { - it('should accept valid pin request', () => { - const req = PinFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - }); - expect(req.feedId).toBe('feed_001'); - }); - - it('should reject missing feedId', () => { - expect(() => - PinFeedItemRequestSchema.parse({ object: 'account', recordId: 'rec_123' }) - ).toThrow(); - }); -}); - -describe('PinFeedItemResponseSchema', () => { - it('should accept valid pin response', () => { - const resp = PinFeedItemResponseSchema.parse({ - success: true, - data: { - feedId: 'feed_001', - pinned: true, - pinnedAt: '2026-01-15T12:00:00Z', - }, - }); - expect(resp.data.pinned).toBe(true); - expect(resp.data.pinnedAt).toBeDefined(); - }); - - it('should reject missing pinnedAt', () => { - expect(() => - PinFeedItemResponseSchema.parse({ - success: true, - data: { feedId: 'feed_001', pinned: true }, - }) - ).toThrow(); - }); -}); - -// ========================================== -// Star Feed Item -// ========================================== - -describe('StarFeedItemRequestSchema', () => { - it('should accept valid star request', () => { - const req = StarFeedItemRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - feedId: 'feed_001', - }); - expect(req.feedId).toBe('feed_001'); - }); - - it('should reject missing object', () => { - expect(() => - StarFeedItemRequestSchema.parse({ recordId: 'rec_123', feedId: 'feed_001' }) - ).toThrow(); - }); -}); - -describe('StarFeedItemResponseSchema', () => { - it('should accept valid star response', () => { - const resp = StarFeedItemResponseSchema.parse({ - success: true, - data: { - feedId: 'feed_001', - starred: true, - starredAt: '2026-01-15T12:00:00Z', - }, - }); - expect(resp.data.starred).toBe(true); - expect(resp.data.starredAt).toBeDefined(); - }); - - it('should reject missing starredAt', () => { - expect(() => - StarFeedItemResponseSchema.parse({ - success: true, - data: { feedId: 'feed_001', starred: true }, - }) - ).toThrow(); - }); -}); - -// ========================================== -// Search Feed -// ========================================== - -describe('SearchFeedRequestSchema', () => { - it('should accept a valid search request with defaults', () => { - const req = SearchFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - query: 'follow up', - }); - expect(req.query).toBe('follow up'); - expect(req.limit).toBe(20); - expect(req.type).toBeUndefined(); - }); - - it('should accept search with all filters', () => { - const req = SearchFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - query: 'budget review', - type: 'comments_only', - actorId: 'user_456', - dateFrom: '2026-01-01T00:00:00Z', - dateTo: '2026-01-31T23:59:59Z', - hasAttachments: true, - pinnedOnly: false, - starredOnly: true, - limit: 50, - cursor: 'cursor_abc', - }); - expect(req.actorId).toBe('user_456'); - expect(req.hasAttachments).toBe(true); - expect(req.starredOnly).toBe(true); - }); - - it('should reject empty query', () => { - expect(() => - SearchFeedRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - query: '', - }) - ).toThrow(); - }); -}); - -describe('SearchFeedResponseSchema', () => { - it('should accept valid search response', () => { - const resp = SearchFeedResponseSchema.parse({ - success: true, - data: { - items: [ - { - id: 'feed_001', - type: 'comment', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456', name: 'John' }, - body: 'Follow up on budget', - createdAt: '2026-01-15T10:30:00Z', - }, - ], - total: 1, - hasMore: false, - }, - }); - expect(resp.data.items).toHaveLength(1); - expect(resp.data.hasMore).toBe(false); - }); -}); - -// ========================================== -// Changelog -// ========================================== - -describe('GetChangelogRequestSchema', () => { - it('should accept request with defaults', () => { - const req = GetChangelogRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - }); - expect(req.limit).toBe(50); - expect(req.field).toBeUndefined(); - }); - - it('should accept request with all filters', () => { - const req = GetChangelogRequestSchema.parse({ - object: 'account', - recordId: 'rec_123', - field: 'status', - actorId: 'user_456', - dateFrom: '2026-01-01T00:00:00Z', - dateTo: '2026-01-31T23:59:59Z', - limit: 100, - cursor: 'cursor_xyz', - }); - expect(req.field).toBe('status'); - expect(req.limit).toBe(100); - }); -}); - -describe('ChangelogEntrySchema', () => { - it('should accept a valid entry', () => { - const entry = ChangelogEntrySchema.parse({ - id: 'cl_001', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456', name: 'Jane' }, - changes: [ - { field: 'status', oldValue: 'draft', newValue: 'active' }, - ], - timestamp: '2026-01-15T10:30:00Z', - source: 'UI', - }); - expect(entry.changes).toHaveLength(1); - expect(entry.actor.type).toBe('user'); - expect(entry.source).toBe('UI'); - }); - - it('should reject empty changes array', () => { - expect(() => - ChangelogEntrySchema.parse({ - id: 'cl_002', - object: 'account', - recordId: 'rec_123', - actor: { type: 'system', id: 'sys' }, - changes: [], - timestamp: '2026-01-15T10:30:00Z', - }) - ).toThrow(); - }); -}); - -describe('GetChangelogResponseSchema', () => { - it('should accept a valid changelog response', () => { - const resp = GetChangelogResponseSchema.parse({ - success: true, - data: { - entries: [ - { - id: 'cl_001', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456' }, - changes: [{ field: 'name', oldValue: 'Old Corp', newValue: 'New Corp' }], - timestamp: '2026-01-15T10:30:00Z', - }, - ], - total: 1, - hasMore: false, - }, - }); - expect(resp.data.entries).toHaveLength(1); - expect(resp.data.hasMore).toBe(false); - }); -}); - -// ========================================== -// New FeedApiErrorCode Values -// ========================================== - -describe('FeedApiErrorCode (new values)', () => { - it('should accept pin-related error codes', () => { - expect(FeedApiErrorCode.parse('feed_already_pinned')).toBe('feed_already_pinned'); - expect(FeedApiErrorCode.parse('feed_not_pinned')).toBe('feed_not_pinned'); - }); - - it('should accept star-related error codes', () => { - expect(FeedApiErrorCode.parse('feed_already_starred')).toBe('feed_already_starred'); - expect(FeedApiErrorCode.parse('feed_not_starred')).toBe('feed_not_starred'); - }); - - it('should accept search-related error codes', () => { - expect(FeedApiErrorCode.parse('feed_search_query_too_short')).toBe('feed_search_query_too_short'); - }); -}); diff --git a/packages/spec/src/api/feed-api.zod.ts b/packages/spec/src/api/feed-api.zod.ts deleted file mode 100644 index 2898cf4c4f..0000000000 --- a/packages/spec/src/api/feed-api.zod.ts +++ /dev/null @@ -1,583 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { BaseResponseSchema } from './contract.zod'; -import { - FeedItemType, - FeedItemSchema, - FeedVisibility, - MentionSchema, - ReactionSchema, - FieldChangeEntrySchema, -} from '../data/feed.zod'; -import { - SubscriptionEventType, - NotificationChannel, - RecordSubscriptionSchema, -} from '../data/subscription.zod'; - -/** - * Feed / Chatter API Protocol - * - * Defines the HTTP interface for the unified activity timeline (Feed). - * Covers Feed CRUD, Emoji Reactions, Pin/Star, Search, Changelog, - * and Record Subscription endpoints. - * - * Base path: /api/data/{object}/{recordId}/feed - * - * @example Endpoints - * GET /api/data/{object}/{recordId}/feed — List feed items - * POST /api/data/{object}/{recordId}/feed — Create feed item - * PUT /api/data/{object}/{recordId}/feed/{feedId} — Update feed item - * DELETE /api/data/{object}/{recordId}/feed/{feedId} — Delete feed item - * POST /api/data/{object}/{recordId}/feed/{feedId}/reactions — Add reaction - * DELETE /api/data/{object}/{recordId}/feed/{feedId}/reactions/{emoji} — Remove reaction - * POST /api/data/{object}/{recordId}/feed/{feedId}/pin — Pin feed item - * DELETE /api/data/{object}/{recordId}/feed/{feedId}/pin — Unpin feed item - * POST /api/data/{object}/{recordId}/feed/{feedId}/star — Star feed item - * DELETE /api/data/{object}/{recordId}/feed/{feedId}/star — Unstar feed item - * GET /api/data/{object}/{recordId}/feed/search — Search feed items - * GET /api/data/{object}/{recordId}/changelog — Get field-level changelog - * POST /api/data/{object}/{recordId}/subscribe — Subscribe - * DELETE /api/data/{object}/{recordId}/subscribe — Unsubscribe - */ - -// ========================================== -// 1. Path Parameters -// ========================================== - -/** - * Common path parameters shared across all feed endpoints. - */ -import { lazySchema } from '../shared/lazy-schema'; -export const FeedPathParamsSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name (e.g., "account")'), - recordId: z.string().describe('Record ID'), -})); -export type FeedPathParams = z.infer; - -/** - * Path parameters for single-feed-item operations (update, delete). - */ -export const FeedItemPathParamsSchema = lazySchema(() => FeedPathParamsSchema.extend({ - feedId: z.string().describe('Feed item ID'), -})); -export type FeedItemPathParams = z.infer; - -// ========================================== -// 2. Feed List (GET) -// ========================================== - -/** - * Feed filter type for the list query. - * Maps to FeedFilterMode: all | comments_only | changes_only | tasks_only - */ -export const FeedListFilterType = z.enum([ - 'all', - 'comments_only', - 'changes_only', - 'tasks_only', -]); - -/** - * Query parameters for listing feed items. - * - * @example GET /api/data/account/rec_123/feed?type=all&limit=20&cursor=xxx - */ -export const GetFeedRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({ - type: FeedListFilterType.default('all') - .describe('Filter by feed item category'), - limit: z.number().int().min(1).max(100).default(20) - .describe('Maximum number of items to return'), - cursor: z.string().optional() - .describe('Cursor for pagination (opaque string from previous response)'), -})); -export type GetFeedRequest = z.infer; - -/** - * Response for the feed list endpoint. - */ -export const GetFeedResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - items: z.array(FeedItemSchema).describe('Feed items in reverse chronological order'), - total: z.number().int().optional().describe('Total feed items matching filter'), - nextCursor: z.string().optional().describe('Cursor for the next page'), - hasMore: z.boolean().describe('Whether more items are available'), - }), -})); -export type GetFeedResponse = z.infer; - -// ========================================== -// 3. Feed Create (POST) -// ========================================== - -/** - * Request body for creating a new feed item (comment, note, task, etc.). - * - * @example POST /api/data/account/rec_123/feed - * { type: 'comment', body: 'Great progress! @jane can you follow up?', mentions: [...] } - */ -export const CreateFeedItemRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({ - type: FeedItemType.describe('Type of feed item to create'), - body: z.string().optional() - .describe('Rich text body (Markdown supported)'), - mentions: z.array(MentionSchema).optional() - .describe('Mentioned users, teams, or records'), - parentId: z.string().optional() - .describe('Parent feed item ID for threaded replies'), - visibility: FeedVisibility.default('public') - .describe('Visibility: public, internal, or private'), -})); -export type CreateFeedItemRequest = z.infer; - -/** - * Response after creating a feed item. - */ -export const CreateFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: FeedItemSchema.describe('The created feed item'), -})); -export type CreateFeedItemResponse = z.infer; - -// ========================================== -// 4. Feed Update (PUT) -// ========================================== - -/** - * Request body for updating an existing feed item (e.g., editing a comment). - * - * @example PUT /api/data/account/rec_123/feed/feed_001 - * { body: 'Updated comment text', mentions: [...] } - */ -export const UpdateFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema.extend({ - body: z.string().optional() - .describe('Updated rich text body'), - mentions: z.array(MentionSchema).optional() - .describe('Updated mentions'), - visibility: FeedVisibility.optional() - .describe('Updated visibility'), -})); -export type UpdateFeedItemRequest = z.infer; - -/** - * Response after updating a feed item. - */ -export const UpdateFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: FeedItemSchema.describe('The updated feed item'), -})); -export type UpdateFeedItemResponse = z.infer; - -// ========================================== -// 5. Feed Delete (DELETE) -// ========================================== - -/** - * Request parameters for deleting a feed item. - * - * @example DELETE /api/data/account/rec_123/feed/feed_001 - */ -export const DeleteFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema); -export type DeleteFeedItemRequest = z.infer; - -/** - * Response after deleting a feed item. - */ -export const DeleteFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - feedId: z.string().describe('ID of the deleted feed item'), - }), -})); -export type DeleteFeedItemResponse = z.infer; - -// ========================================== -// 6. Reactions (POST / DELETE) -// ========================================== - -/** - * Request for adding an emoji reaction to a feed item. - * - * @example POST /api/data/account/rec_123/feed/feed_001/reactions - * { emoji: '👍' } - */ -export const AddReactionRequestSchema = lazySchema(() => FeedItemPathParamsSchema.extend({ - emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'), -})); -export type AddReactionRequest = z.infer; - -/** - * Response after adding a reaction. - */ -export const AddReactionResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - reactions: z.array(ReactionSchema).describe('Updated reaction list for the feed item'), - }), -})); -export type AddReactionResponse = z.infer; - -/** - * Request for removing an emoji reaction from a feed item. - * - * @example DELETE /api/data/account/rec_123/feed/feed_001/reactions/👍 - */ -export const RemoveReactionRequestSchema = lazySchema(() => FeedItemPathParamsSchema.extend({ - emoji: z.string().describe('Emoji character or shortcode to remove'), -})); -export type RemoveReactionRequest = z.infer; - -/** - * Response after removing a reaction. - */ -export const RemoveReactionResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - reactions: z.array(ReactionSchema).describe('Updated reaction list for the feed item'), - }), -})); -export type RemoveReactionResponse = z.infer; - -// ========================================== -// 7. Pin / Star -// ========================================== - -/** - * Request for pinning a feed item to the top of the timeline. - * - * @example POST /api/data/account/rec_123/feed/feed_001/pin - */ -export const PinFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema); -export type PinFeedItemRequest = z.infer; - -/** - * Response after pinning a feed item. - */ -export const PinFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - feedId: z.string().describe('ID of the pinned feed item'), - pinned: z.boolean().describe('Whether the item is now pinned'), - pinnedAt: z.string().datetime().describe('Timestamp when pinned'), - }), -})); -export type PinFeedItemResponse = z.infer; - -/** - * Request for unpinning a feed item. - * - * @example DELETE /api/data/account/rec_123/feed/feed_001/pin - */ -export const UnpinFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema); -export type UnpinFeedItemRequest = z.infer; - -/** - * Response after unpinning a feed item. - */ -export const UnpinFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - feedId: z.string().describe('ID of the unpinned feed item'), - pinned: z.boolean().describe('Whether the item is now pinned (should be false)'), - }), -})); -export type UnpinFeedItemResponse = z.infer; - -/** - * Request for starring (bookmarking) a feed item. - * - * @example POST /api/data/account/rec_123/feed/feed_001/star - */ -export const StarFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema); -export type StarFeedItemRequest = z.infer; - -/** - * Response after starring a feed item. - */ -export const StarFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - feedId: z.string().describe('ID of the starred feed item'), - starred: z.boolean().describe('Whether the item is now starred'), - starredAt: z.string().datetime().describe('Timestamp when starred'), - }), -})); -export type StarFeedItemResponse = z.infer; - -/** - * Request for unstarring a feed item. - * - * @example DELETE /api/data/account/rec_123/feed/feed_001/star - */ -export const UnstarFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema); -export type UnstarFeedItemRequest = z.infer; - -/** - * Response after unstarring a feed item. - */ -export const UnstarFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - feedId: z.string().describe('ID of the unstarred feed item'), - starred: z.boolean().describe('Whether the item is now starred (should be false)'), - }), -})); -export type UnstarFeedItemResponse = z.infer; - -// ========================================== -// 8. Activity Feed Search & Filter -// ========================================== - -/** - * Request for searching feed items with full-text query and advanced filters. - * - * @example GET /api/data/account/rec_123/feed/search?query=follow+up&actorId=user_456&dateFrom=2026-01-01T00:00:00Z - */ -export const SearchFeedRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({ - query: z.string().min(1).describe('Full-text search query against feed body content'), - type: FeedListFilterType.optional() - .describe('Filter by feed item category'), - actorId: z.string().optional() - .describe('Filter by actor user ID'), - dateFrom: z.string().datetime().optional() - .describe('Filter feed items created after this timestamp'), - dateTo: z.string().datetime().optional() - .describe('Filter feed items created before this timestamp'), - hasAttachments: z.boolean().optional() - .describe('Filter for items with file attachments'), - pinnedOnly: z.boolean().optional() - .describe('Return only pinned items'), - starredOnly: z.boolean().optional() - .describe('Return only starred items'), - limit: z.number().int().min(1).max(100).default(20) - .describe('Maximum number of items to return'), - cursor: z.string().optional() - .describe('Cursor for pagination'), -})); -export type SearchFeedRequest = z.infer; - -/** - * Response for the feed search endpoint. - */ -export const SearchFeedResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - items: z.array(FeedItemSchema).describe('Matching feed items sorted by relevance'), - total: z.number().int().optional().describe('Total matching items'), - nextCursor: z.string().optional().describe('Cursor for the next page'), - hasMore: z.boolean().describe('Whether more items are available'), - }), -})); -export type SearchFeedResponse = z.infer; - -// ========================================== -// 9. Changelog (Field-Level Audit Trail) -// ========================================== - -/** - * Request for retrieving the field-level changelog of a record. - * - * @example GET /api/data/account/rec_123/changelog?field=status&limit=50 - */ -export const GetChangelogRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({ - field: z.string().optional() - .describe('Filter changelog to a specific field name'), - actorId: z.string().optional() - .describe('Filter changelog by actor user ID'), - dateFrom: z.string().datetime().optional() - .describe('Filter changes after this timestamp'), - dateTo: z.string().datetime().optional() - .describe('Filter changes before this timestamp'), - limit: z.number().int().min(1).max(200).default(50) - .describe('Maximum number of changelog entries to return'), - cursor: z.string().optional() - .describe('Cursor for pagination'), -})); -export type GetChangelogRequest = z.infer; - -/** - * A single changelog entry representing one or more field changes at a point in time. - */ -export const ChangelogEntrySchema = lazySchema(() => z.object({ - id: z.string().describe('Changelog entry ID'), - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID'), - actor: z.object({ - type: z.enum(['user', 'system', 'service', 'automation']).describe('Actor type'), - id: z.string().describe('Actor ID'), - name: z.string().optional().describe('Actor display name'), - }).describe('Who made the change'), - changes: z.array(FieldChangeEntrySchema).min(1).describe('Field-level changes'), - timestamp: z.string().datetime().describe('When the change occurred'), - source: z.string().optional().describe('Change source (e.g., "API", "UI", "automation")'), -})); -export type ChangelogEntry = z.infer; - -/** - * Response for the changelog endpoint. - */ -export const GetChangelogResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - entries: z.array(ChangelogEntrySchema).describe('Changelog entries in reverse chronological order'), - total: z.number().int().optional().describe('Total changelog entries matching filter'), - nextCursor: z.string().optional().describe('Cursor for the next page'), - hasMore: z.boolean().describe('Whether more entries are available'), - }), -})); -export type GetChangelogResponse = z.infer; - -// ========================================== -// 10. Record Subscription (POST / DELETE) -// ========================================== - -/** - * Request for subscribing to record notifications. - * - * @example POST /api/data/account/rec_123/subscribe - * { events: ['comment', 'field_change'], channels: ['in_app', 'email'] } - */ -export const SubscribeRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({ - events: z.array(SubscriptionEventType).default(['all']) - .describe('Event types to subscribe to'), - channels: z.array(NotificationChannel).default(['in_app']) - .describe('Notification delivery channels'), -})); -export type SubscribeRequest = z.infer; - -/** - * Response after subscribing. - */ -export const SubscribeResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: RecordSubscriptionSchema.describe('The created or updated subscription'), -})); -export type SubscribeResponse = z.infer; - -/** - * Request for unsubscribing from record notifications. - * - * @example DELETE /api/data/account/rec_123/subscribe - */ -export const FeedUnsubscribeRequestSchema = lazySchema(() => FeedPathParamsSchema); -export type FeedUnsubscribeRequest = z.infer; - -/** - * Response after unsubscribing. - */ -export const UnsubscribeResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID'), - unsubscribed: z.boolean().describe('Whether the user was unsubscribed'), - }), -})); -export type UnsubscribeResponse = z.infer; - -// ========================================== -// 11. Feed API Error Codes -// ========================================== - -/** - * Error codes specific to Feed/Chatter operations. - */ -export const FeedApiErrorCode = z.enum([ - 'feed_item_not_found', - 'feed_permission_denied', - 'feed_item_not_editable', - 'feed_invalid_parent', - 'reaction_already_exists', - 'reaction_not_found', - 'subscription_already_exists', - 'subscription_not_found', - 'invalid_feed_type', - 'feed_already_pinned', - 'feed_not_pinned', - 'feed_already_starred', - 'feed_not_starred', - 'feed_search_query_too_short', -]); -export type FeedApiErrorCode = z.infer; - -// ========================================== -// 12. Feed API Contract Registry -// ========================================== - -/** - * Standard Feed API contracts map. - * Used for generating SDKs, documentation, and route registration. - */ -export const FeedApiContracts = { - listFeed: { - method: 'GET' as const, - path: '/api/data/:object/:recordId/feed', - input: GetFeedRequestSchema, - output: GetFeedResponseSchema, - }, - createFeedItem: { - method: 'POST' as const, - path: '/api/data/:object/:recordId/feed', - input: CreateFeedItemRequestSchema, - output: CreateFeedItemResponseSchema, - }, - updateFeedItem: { - method: 'PUT' as const, - path: '/api/data/:object/:recordId/feed/:feedId', - input: UpdateFeedItemRequestSchema, - output: UpdateFeedItemResponseSchema, - }, - deleteFeedItem: { - method: 'DELETE' as const, - path: '/api/data/:object/:recordId/feed/:feedId', - input: DeleteFeedItemRequestSchema, - output: DeleteFeedItemResponseSchema, - }, - addReaction: { - method: 'POST' as const, - path: '/api/data/:object/:recordId/feed/:feedId/reactions', - input: AddReactionRequestSchema, - output: AddReactionResponseSchema, - }, - removeReaction: { - method: 'DELETE' as const, - path: '/api/data/:object/:recordId/feed/:feedId/reactions/:emoji', - input: RemoveReactionRequestSchema, - output: RemoveReactionResponseSchema, - }, - pinFeedItem: { - method: 'POST' as const, - path: '/api/data/:object/:recordId/feed/:feedId/pin', - input: PinFeedItemRequestSchema, - output: PinFeedItemResponseSchema, - }, - unpinFeedItem: { - method: 'DELETE' as const, - path: '/api/data/:object/:recordId/feed/:feedId/pin', - input: UnpinFeedItemRequestSchema, - output: UnpinFeedItemResponseSchema, - }, - starFeedItem: { - method: 'POST' as const, - path: '/api/data/:object/:recordId/feed/:feedId/star', - input: StarFeedItemRequestSchema, - output: StarFeedItemResponseSchema, - }, - unstarFeedItem: { - method: 'DELETE' as const, - path: '/api/data/:object/:recordId/feed/:feedId/star', - input: UnstarFeedItemRequestSchema, - output: UnstarFeedItemResponseSchema, - }, - searchFeed: { - method: 'GET' as const, - path: '/api/data/:object/:recordId/feed/search', - input: SearchFeedRequestSchema, - output: SearchFeedResponseSchema, - }, - getChangelog: { - method: 'GET' as const, - path: '/api/data/:object/:recordId/changelog', - input: GetChangelogRequestSchema, - output: GetChangelogResponseSchema, - }, - subscribe: { - method: 'POST' as const, - path: '/api/data/:object/:recordId/subscribe', - input: SubscribeRequestSchema, - output: SubscribeResponseSchema, - }, - unsubscribe: { - method: 'DELETE' as const, - path: '/api/data/:object/:recordId/subscribe', - input: FeedUnsubscribeRequestSchema, - output: UnsubscribeResponseSchema, - }, -}; diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 5f1b905031..bbe6068f73 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -43,7 +43,6 @@ export * from './metadata.zod'; export * from './dispatcher.zod'; export * from './plugin-rest-api.zod'; export * from './query-adapter.zod'; -export * from './feed-api.zod'; export * from './export.zod'; export * from './automation-api.zod'; export * from './package-api.zod'; diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index d180f0f222..d814f43d8c 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -17,36 +17,6 @@ import { ObjectPermissionSchema, FieldPermissionSchema } from '../security/permi import { StateMachineSchema } from '../automation/state-machine.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; import { TranslationDataSchema } from '../system/translation.zod'; -import type { - GetFeedRequest, - GetFeedResponse, - CreateFeedItemRequest, - CreateFeedItemResponse, - UpdateFeedItemRequest, - UpdateFeedItemResponse, - DeleteFeedItemRequest, - DeleteFeedItemResponse, - AddReactionRequest, - AddReactionResponse, - RemoveReactionRequest, - RemoveReactionResponse, - PinFeedItemRequest, - PinFeedItemResponse, - UnpinFeedItemRequest, - UnpinFeedItemResponse, - StarFeedItemRequest, - StarFeedItemResponse, - UnstarFeedItemRequest, - UnstarFeedItemResponse, - SearchFeedRequest, - SearchFeedResponse, - GetChangelogRequest, - GetChangelogResponse, - SubscribeRequest, - SubscribeResponse, - FeedUnsubscribeRequest, - UnsubscribeResponse, -} from './feed-api.zod'; import { ListPackagesRequestSchema, ListPackagesResponseSchema, @@ -1152,36 +1122,6 @@ export const ObjectStackProtocolSchema = lazySchema(() => z.object({ .describe('Get translations for a locale'), getFieldLabels: z.function() .describe('Get translated field labels for an object'), - - // Feed Operations - listFeed: z.function() - .describe('List feed items for a record'), - createFeedItem: z.function() - .describe('Create a new feed item'), - updateFeedItem: z.function() - .describe('Update an existing feed item'), - deleteFeedItem: z.function() - .describe('Delete a feed item'), - addReaction: z.function() - .describe('Add an emoji reaction to a feed item'), - removeReaction: z.function() - .describe('Remove an emoji reaction from a feed item'), - pinFeedItem: z.function() - .describe('Pin a feed item'), - unpinFeedItem: z.function() - .describe('Unpin a feed item'), - starFeedItem: z.function() - .describe('Star a feed item'), - unstarFeedItem: z.function() - .describe('Unstar a feed item'), - searchFeed: z.function() - .describe('Search feed items'), - getChangelog: z.function() - .describe('Get field-level changelog for a record'), - feedSubscribe: z.function() - .describe('Subscribe to record notifications'), - feedUnsubscribe: z.function() - .describe('Unsubscribe from record notifications'), })); /** @@ -1313,38 +1253,6 @@ export type GetTranslationsResponse = z.infer; export type GetFieldLabelsResponse = z.infer; -// Feed Types (re-exported from feed-api.zod.ts for convenience) -export type { - GetFeedRequest, - GetFeedResponse, - CreateFeedItemRequest, - CreateFeedItemResponse, - UpdateFeedItemRequest, - UpdateFeedItemResponse, - DeleteFeedItemRequest, - DeleteFeedItemResponse, - AddReactionRequest, - AddReactionResponse, - RemoveReactionRequest, - RemoveReactionResponse, - PinFeedItemRequest, - PinFeedItemResponse, - UnpinFeedItemRequest, - UnpinFeedItemResponse, - StarFeedItemRequest, - StarFeedItemResponse, - UnstarFeedItemRequest, - UnstarFeedItemResponse, - SearchFeedRequest, - SearchFeedResponse, - GetChangelogRequest, - GetChangelogResponse, - SubscribeRequest, - SubscribeResponse, - FeedUnsubscribeRequest, - UnsubscribeResponse, -} from './feed-api.zod'; - // Package Management Types (re-exported from kernel for convenience) export type { ListPackagesRequest, @@ -1500,24 +1408,6 @@ export interface I18nProtocol { getFieldLabels?(request: GetFieldLabelsRequest): Promise; } -/** Feed / social (optional). */ -export interface FeedProtocol { - listFeed?(request: GetFeedRequest): Promise; - createFeedItem?(request: CreateFeedItemRequest): Promise; - updateFeedItem?(request: UpdateFeedItemRequest): Promise; - deleteFeedItem?(request: DeleteFeedItemRequest): Promise; - addReaction?(request: AddReactionRequest): Promise; - removeReaction?(request: RemoveReactionRequest): Promise; - pinFeedItem?(request: PinFeedItemRequest): Promise; - unpinFeedItem?(request: UnpinFeedItemRequest): Promise; - starFeedItem?(request: StarFeedItemRequest): Promise; - unstarFeedItem?(request: UnstarFeedItemRequest): Promise; - searchFeed?(request: SearchFeedRequest): Promise; - getChangelog?(request: GetChangelogRequest): Promise; - feedSubscribe?(request: SubscribeRequest): Promise; - feedUnsubscribe?(request: FeedUnsubscribeRequest): Promise; -} - /** * ObjectStackProtocol — composition of the per-domain contracts above * (ADR-0076 D9). Shape-identical to the historical flat interface, so every @@ -1538,5 +1428,4 @@ export interface ObjectStackProtocol extends RealtimeProtocol, NotificationProtocol, AiProtocol, - I18nProtocol, - FeedProtocol {} + I18nProtocol {} diff --git a/packages/spec/src/contracts/feed-service.test.ts b/packages/spec/src/contracts/feed-service.test.ts deleted file mode 100644 index 68a203eefa..0000000000 --- a/packages/spec/src/contracts/feed-service.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import type { IFeedService } from './feed-service'; - -describe('Feed Service Contract', () => { - it('should allow a minimal IFeedService implementation with all required methods', () => { - const service: IFeedService = { - listFeed: async () => ({ items: [], hasMore: false }), - createFeedItem: async () => ({ - id: 'feed_1', - type: 'comment', - object: 'account', - recordId: 'rec_1', - actor: { type: 'user', id: 'user_1' }, - visibility: 'public', - replyCount: 0, - isEdited: false, - createdAt: new Date().toISOString(), - }), - updateFeedItem: async () => ({ - id: 'feed_1', - type: 'comment', - object: 'account', - recordId: 'rec_1', - actor: { type: 'user', id: 'user_1' }, - visibility: 'public', - replyCount: 0, - isEdited: true, - createdAt: new Date().toISOString(), - }), - deleteFeedItem: async () => {}, - getFeedItem: async () => null, - addReaction: async () => [], - removeReaction: async () => [], - subscribe: async () => ({ - object: 'account', - recordId: 'rec_1', - userId: 'user_1', - events: ['all'], - channels: ['in_app'], - active: true, - createdAt: new Date().toISOString(), - }), - unsubscribe: async () => true, - getSubscription: async () => null, - }; - - expect(typeof service.listFeed).toBe('function'); - expect(typeof service.createFeedItem).toBe('function'); - expect(typeof service.updateFeedItem).toBe('function'); - expect(typeof service.deleteFeedItem).toBe('function'); - expect(typeof service.getFeedItem).toBe('function'); - expect(typeof service.addReaction).toBe('function'); - expect(typeof service.removeReaction).toBe('function'); - expect(typeof service.subscribe).toBe('function'); - expect(typeof service.unsubscribe).toBe('function'); - expect(typeof service.getSubscription).toBe('function'); - }); - - it('should create and retrieve a feed item', async () => { - const items = new Map(); - let counter = 0; - - const service: IFeedService = { - listFeed: async () => ({ items: Array.from(items.values()), hasMore: false }), - createFeedItem: async (input) => { - const id = `feed_${++counter}`; - const item = { - id, - type: input.type as any, - object: input.object, - recordId: input.recordId, - actor: input.actor, - body: input.body, - visibility: input.visibility ?? 'public', - replyCount: 0, - isEdited: false, - createdAt: new Date().toISOString(), - }; - items.set(id, item); - return item; - }, - updateFeedItem: async () => ({} as any), - deleteFeedItem: async () => {}, - getFeedItem: async (feedId) => items.get(feedId) ?? null, - addReaction: async () => [], - removeReaction: async () => [], - subscribe: async () => ({} as any), - unsubscribe: async () => true, - getSubscription: async () => null, - }; - - const item = await service.createFeedItem({ - object: 'account', - recordId: 'rec_123', - type: 'comment', - actor: { type: 'user', id: 'user_1', name: 'Alice' }, - body: 'Hello world', - }); - - expect(item.id).toBeDefined(); - expect(item.body).toBe('Hello world'); - - const fetched = await service.getFeedItem(item.id); - expect(fetched).toEqual(item); - }); - - it('should list feed items', async () => { - const service: IFeedService = { - listFeed: async (options) => ({ - items: [ - { - id: 'feed_1', - type: 'comment', - object: options.object, - recordId: options.recordId, - actor: { type: 'user', id: 'user_1' }, - visibility: 'public', - replyCount: 0, - isEdited: false, - createdAt: new Date().toISOString(), - }, - ], - total: 1, - hasMore: false, - }), - createFeedItem: async () => ({} as any), - updateFeedItem: async () => ({} as any), - deleteFeedItem: async () => {}, - getFeedItem: async () => null, - addReaction: async () => [], - removeReaction: async () => [], - subscribe: async () => ({} as any), - unsubscribe: async () => true, - getSubscription: async () => null, - }; - - const result = await service.listFeed({ object: 'account', recordId: 'rec_123' }); - expect(result.items).toHaveLength(1); - expect(result.hasMore).toBe(false); - }); - - it('should handle subscribe and unsubscribe', async () => { - const subs = new Map(); - - const service: IFeedService = { - listFeed: async () => ({ items: [], hasMore: false }), - createFeedItem: async () => ({} as any), - updateFeedItem: async () => ({} as any), - deleteFeedItem: async () => {}, - getFeedItem: async () => null, - addReaction: async () => [], - removeReaction: async () => [], - subscribe: async (input) => { - const sub = { - object: input.object, - recordId: input.recordId, - userId: input.userId, - events: input.events ?? ['all'], - channels: input.channels ?? ['in_app'], - active: true, - createdAt: new Date().toISOString(), - }; - subs.set(`${input.object}:${input.recordId}:${input.userId}`, sub); - return sub; - }, - unsubscribe: async (object, recordId, userId) => { - return subs.delete(`${object}:${recordId}:${userId}`); - }, - getSubscription: async (object, recordId, userId) => { - return subs.get(`${object}:${recordId}:${userId}`) ?? null; - }, - }; - - const sub = await service.subscribe({ - object: 'account', - recordId: 'rec_123', - userId: 'user_1', - events: ['comment'], - }); - expect(sub.active).toBe(true); - expect(sub.events).toEqual(['comment']); - - const fetched = await service.getSubscription('account', 'rec_123', 'user_1'); - expect(fetched).not.toBeNull(); - - const result = await service.unsubscribe('account', 'rec_123', 'user_1'); - expect(result).toBe(true); - - const gone = await service.getSubscription('account', 'rec_123', 'user_1'); - expect(gone).toBeNull(); - }); -}); diff --git a/packages/spec/src/contracts/feed-service.ts b/packages/spec/src/contracts/feed-service.ts deleted file mode 100644 index 0d1e84a182..0000000000 --- a/packages/spec/src/contracts/feed-service.ts +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * IFeedService - Feed/Chatter Service Contract - * - * Defines the interface for Feed/Chatter operations in ObjectStack. - * Covers feed CRUD, emoji reactions, and record subscriptions. - * Concrete implementations (in-memory, database-backed, etc.) - * should implement this interface. - * - * Follows Dependency Inversion Principle - plugins depend on this interface, - * not on concrete feed service implementations. - * - * Aligned with CoreServiceName 'feed' in core-services.zod.ts. - */ - -import type { FeedItem, Reaction } from '../data/feed.zod'; -import type { RecordSubscription } from '../data/subscription.zod'; - -// ========================================== -// Feed Item Types -// ========================================== - -/** - * Input for creating a new feed item. - */ -export interface CreateFeedItemInput { - /** Object name (e.g., "account") */ - object: string; - /** Record ID */ - recordId: string; - /** Feed item type */ - type: string; - /** Actor information */ - actor: { - type: 'user' | 'system' | 'service' | 'automation'; - id: string; - name?: string; - avatarUrl?: string; - }; - /** Rich text body (Markdown) */ - body?: string; - /** @mentions */ - mentions?: Array<{ - type: 'user' | 'team' | 'record'; - id: string; - name: string; - offset: number; - length: number; - }>; - /** Field changes (for field_change type) */ - changes?: Array<{ - field: string; - fieldLabel?: string; - oldValue?: unknown; - newValue?: unknown; - oldDisplayValue?: string; - newDisplayValue?: string; - }>; - /** Parent feed item ID for threaded replies */ - parentId?: string; - /** Visibility level */ - visibility?: 'public' | 'internal' | 'private'; -} - -/** - * Input for updating an existing feed item. - */ -export interface UpdateFeedItemInput { - /** Updated body text */ - body?: string; - /** Updated mentions */ - mentions?: Array<{ - type: 'user' | 'team' | 'record'; - id: string; - name: string; - offset: number; - length: number; - }>; - /** Updated visibility */ - visibility?: 'public' | 'internal' | 'private'; -} - -/** - * Options for listing feed items. - */ -export interface ListFeedOptions { - /** Object name */ - object: string; - /** Record ID */ - recordId: string; - /** Filter mode */ - filter?: 'all' | 'comments_only' | 'changes_only' | 'tasks_only'; - /** Maximum items to return */ - limit?: number; - /** Cursor for pagination */ - cursor?: string; -} - -/** - * Paginated feed list result. - */ -export interface FeedListResult { - /** Feed items in reverse chronological order */ - items: FeedItem[]; - /** Total feed items matching filter */ - total?: number; - /** Cursor for next page */ - nextCursor?: string; - /** Whether more items are available */ - hasMore: boolean; -} - -// ========================================== -// Subscription Types -// ========================================== - -/** - * Input for subscribing to record notifications. - */ -export interface SubscribeInput { - /** Object name */ - object: string; - /** Record ID */ - recordId: string; - /** Subscribing user ID */ - userId: string; - /** Event types to subscribe to */ - events?: Array<'comment' | 'mention' | 'field_change' | 'task' | 'approval' | 'all'>; - /** Notification channels */ - channels?: Array<'in_app' | 'email' | 'push' | 'slack'>; -} - -// ========================================== -// Service Interface -// ========================================== - -export interface IFeedService { - // ---- Feed CRUD ---- - - /** - * List feed items for a record. - * @param options - Filter and pagination options - * @returns Paginated list of feed items - */ - listFeed(options: ListFeedOptions): Promise; - - /** - * Create a new feed item. - * @param input - Feed item data - * @returns The created feed item - */ - createFeedItem(input: CreateFeedItemInput): Promise; - - /** - * Update an existing feed item (e.g., edit a comment). - * @param feedId - Feed item ID - * @param input - Updated fields - * @returns The updated feed item - */ - updateFeedItem(feedId: string, input: UpdateFeedItemInput): Promise; - - /** - * Delete a feed item. - * @param feedId - Feed item ID - */ - deleteFeedItem(feedId: string): Promise; - - /** - * Get a single feed item by ID. - * @param feedId - Feed item ID - * @returns The feed item, or null if not found - */ - getFeedItem(feedId: string): Promise; - - // ---- Reactions ---- - - /** - * Add an emoji reaction to a feed item. - * @param feedId - Feed item ID - * @param emoji - Emoji character or shortcode - * @param userId - User adding the reaction - * @returns Updated reactions list - */ - addReaction(feedId: string, emoji: string, userId: string): Promise; - - /** - * Remove an emoji reaction from a feed item. - * @param feedId - Feed item ID - * @param emoji - Emoji character or shortcode - * @param userId - User removing the reaction - * @returns Updated reactions list - */ - removeReaction(feedId: string, emoji: string, userId: string): Promise; - - // ---- Subscriptions ---- - - /** - * Subscribe to record-level notifications. - * @param input - Subscription details - * @returns The created or updated subscription - */ - subscribe(input: SubscribeInput): Promise; - - /** - * Unsubscribe from record notifications. - * @param object - Object name - * @param recordId - Record ID - * @param userId - User ID - * @returns Whether the user was unsubscribed - */ - unsubscribe(object: string, recordId: string, userId: string): Promise; - - /** - * Get a user's subscription for a record. - * @param object - Object name - * @param recordId - Record ID - * @param userId - User ID - * @returns The subscription, or null if not subscribed - */ - getSubscription(object: string, recordId: string, userId: string): Promise; -} diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 8a1a4306c1..a1c5867f71 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -32,7 +32,6 @@ export * from './ai-service.js'; export * from './llm-adapter.js'; export * from './i18n-service.js'; export * from './workflow-service.js'; -export * from './feed-service.js'; export * from './export-service.js'; export * from './email-service.js'; export * from './sms-service.js'; diff --git a/packages/spec/src/data/feed.test.ts b/packages/spec/src/data/feed.test.ts index 934ef97be8..3a59a53a9f 100644 --- a/packages/spec/src/data/feed.test.ts +++ b/packages/spec/src/data/feed.test.ts @@ -1,19 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - FeedItemType, - MentionSchema, - FieldChangeEntrySchema, - ReactionSchema, - FeedActorSchema, - FeedVisibility, - FeedItemSchema, - FeedFilterMode, - type FeedItem, - type Mention, - type FieldChangeEntry, - type Reaction, - type FeedActor, -} from './feed.zod'; +import { FeedItemType, FeedFilterMode } from './feed.zod'; describe('FeedItemType', () => { it('should accept all valid feed item types', () => { @@ -33,151 +19,6 @@ describe('FeedItemType', () => { }); }); -describe('MentionSchema', () => { - it('should accept a valid user mention', () => { - const mention: Mention = { - type: 'user', - id: 'user_123', - name: 'Jane Doe', - offset: 17, - length: 9, - }; - const result = MentionSchema.parse(mention); - expect(result.type).toBe('user'); - expect(result.id).toBe('user_123'); - expect(result.name).toBe('Jane Doe'); - expect(result.offset).toBe(17); - expect(result.length).toBe(9); - }); - - it('should accept team and record mention types', () => { - expect(() => MentionSchema.parse({ type: 'team', id: 'team_1', name: 'Engineering', offset: 0, length: 12 })).not.toThrow(); - expect(() => MentionSchema.parse({ type: 'record', id: 'rec_1', name: 'Acme Corp', offset: 5, length: 9 })).not.toThrow(); - }); - - it('should reject invalid mention type', () => { - expect(() => MentionSchema.parse({ type: 'group', id: '1', name: 'X', offset: 0, length: 1 })).toThrow(); - }); - - it('should reject negative offset', () => { - expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: -1, length: 1 })).toThrow(); - }); - - it('should reject zero length', () => { - expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: 0, length: 0 })).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => MentionSchema.parse({})).toThrow(); - expect(() => MentionSchema.parse({ type: 'user' })).toThrow(); - }); -}); - -describe('FieldChangeEntrySchema', () => { - it('should accept minimal field change', () => { - const result = FieldChangeEntrySchema.parse({ field: 'status' }); - expect(result.field).toBe('status'); - expect(result.oldValue).toBeUndefined(); - expect(result.newValue).toBeUndefined(); - }); - - it('should accept full field change with display values', () => { - const change: FieldChangeEntry = { - field: 'region', - fieldLabel: 'Region', - oldValue: null, - newValue: 'asia_pacific', - oldDisplayValue: '', - newDisplayValue: 'Asia-Pacific', - }; - const result = FieldChangeEntrySchema.parse(change); - expect(result.fieldLabel).toBe('Region'); - expect(result.newDisplayValue).toBe('Asia-Pacific'); - }); - - it('should reject without field name', () => { - expect(() => FieldChangeEntrySchema.parse({})).toThrow(); - }); -}); - -describe('ReactionSchema', () => { - it('should accept valid reaction', () => { - const reaction: Reaction = { - emoji: '👍', - userIds: ['user_1', 'user_2'], - count: 2, - }; - const result = ReactionSchema.parse(reaction); - expect(result.emoji).toBe('👍'); - expect(result.userIds).toHaveLength(2); - expect(result.count).toBe(2); - }); - - it('should reject count less than 1', () => { - expect(() => ReactionSchema.parse({ emoji: '👍', userIds: [], count: 0 })).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => ReactionSchema.parse({})).toThrow(); - expect(() => ReactionSchema.parse({ emoji: '👍' })).toThrow(); - }); -}); - -describe('FeedActorSchema', () => { - it('should accept user actor', () => { - const actor: FeedActor = { - type: 'user', - id: 'user_456', - name: 'John Smith', - }; - const result = FeedActorSchema.parse(actor); - expect(result.type).toBe('user'); - expect(result.name).toBe('John Smith'); - }); - - it('should accept system actor with source', () => { - const result = FeedActorSchema.parse({ - type: 'system', - id: 'sys_001', - source: 'Omni', - }); - expect(result.type).toBe('system'); - expect(result.source).toBe('Omni'); - }); - - it('should accept all actor types', () => { - const types = ['user', 'system', 'service', 'automation']; - types.forEach(type => { - expect(() => FeedActorSchema.parse({ type, id: 'test_1' })).not.toThrow(); - }); - }); - - it('should accept actor with avatarUrl', () => { - const result = FeedActorSchema.parse({ - type: 'user', - id: 'user_1', - avatarUrl: 'https://example.com/avatar.png', - }); - expect(result.avatarUrl).toBe('https://example.com/avatar.png'); - }); - - it('should reject invalid actor type', () => { - expect(() => FeedActorSchema.parse({ type: 'bot', id: '1' })).toThrow(); - }); -}); - -describe('FeedVisibility', () => { - it('should accept valid visibility levels', () => { - ['public', 'internal', 'private'].forEach(v => { - expect(() => FeedVisibility.parse(v)).not.toThrow(); - }); - }); - - it('should reject invalid visibility', () => { - expect(() => FeedVisibility.parse('secret')).toThrow(); - }); -}); - describe('FeedFilterMode', () => { it('should accept valid filter modes', () => { ['all', 'comments_only', 'changes_only', 'tasks_only'].forEach(mode => { @@ -189,149 +30,3 @@ describe('FeedFilterMode', () => { expect(() => FeedFilterMode.parse('custom')).toThrow(); }); }); - -describe('FeedItemSchema', () => { - const minimalComment: FeedItem = { - id: 'feed_001', - type: 'comment', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456', name: 'John Smith' }, - body: 'Great progress on this deal!', - createdAt: '2026-01-15T10:30:00Z', - }; - - it('should accept a minimal comment feed item', () => { - const result = FeedItemSchema.parse(minimalComment); - expect(result.id).toBe('feed_001'); - expect(result.type).toBe('comment'); - expect(result.object).toBe('account'); - expect(result.recordId).toBe('rec_123'); - expect(result.body).toBe('Great progress on this deal!'); - expect(result.replyCount).toBe(0); - expect(result.visibility).toBe('public'); - expect(result.isEdited).toBe(false); - }); - - it('should accept a comment with mentions', () => { - const result = FeedItemSchema.parse({ - ...minimalComment, - mentions: [ - { type: 'user', id: 'user_789', name: 'Jane Doe', offset: 17, length: 9 }, - ], - }); - expect(result.mentions).toHaveLength(1); - expect(result.mentions![0].name).toBe('Jane Doe'); - }); - - it('should accept a field_change feed item with changes', () => { - const fieldChange: FeedItem = { - id: 'feed_002', - type: 'field_change', - object: 'account', - recordId: 'rec_123', - actor: { type: 'user', id: 'user_456', name: 'John Smith' }, - changes: [ - { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' }, - { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' }, - ], - createdAt: '2026-01-15T10:25:00Z', - }; - const result = FeedItemSchema.parse(fieldChange); - expect(result.type).toBe('field_change'); - expect(result.changes).toHaveLength(2); - expect(result.changes![0].field).toBe('status'); - }); - - it('should accept a threaded reply', () => { - const result = FeedItemSchema.parse({ - ...minimalComment, - id: 'feed_003', - parentId: 'feed_001', - }); - expect(result.parentId).toBe('feed_001'); - }); - - it('should accept edited comment', () => { - const result = FeedItemSchema.parse({ - ...minimalComment, - isEdited: true, - editedAt: '2026-01-15T11:00:00Z', - }); - expect(result.isEdited).toBe(true); - expect(result.editedAt).toBe('2026-01-15T11:00:00Z'); - }); - - it('should accept reactions on a feed item', () => { - const result = FeedItemSchema.parse({ - ...minimalComment, - reactions: [ - { emoji: '👍', userIds: ['user_789'], count: 1 }, - { emoji: '❤️', userIds: ['user_101', 'user_102'], count: 2 }, - ], - }); - expect(result.reactions).toHaveLength(2); - }); - - it('should accept internal visibility', () => { - const result = FeedItemSchema.parse({ - ...minimalComment, - visibility: 'internal', - }); - expect(result.visibility).toBe('internal'); - }); - - it('should accept system actor with source', () => { - const result = FeedItemSchema.parse({ - ...minimalComment, - actor: { type: 'system', id: 'sys_001', source: 'API' }, - }); - expect(result.actor.type).toBe('system'); - expect(result.actor.source).toBe('API'); - }); - - it('should accept all feed item types', () => { - const types = [ - 'comment', 'field_change', 'task', 'event', 'email', 'call', - 'note', 'file', 'record_create', 'record_delete', 'approval', - 'sharing', 'system', - ]; - types.forEach(type => { - expect(() => FeedItemSchema.parse({ - id: `feed_${type}`, - type, - object: 'account', - recordId: 'rec_1', - actor: { type: 'user', id: 'user_1' }, - createdAt: '2026-01-15T10:00:00Z', - })).not.toThrow(); - }); - }); - - it('should apply default values', () => { - const result = FeedItemSchema.parse({ - id: 'feed_def', - type: 'note', - object: 'lead', - recordId: 'rec_1', - actor: { type: 'user', id: 'user_1' }, - createdAt: '2026-01-15T10:00:00Z', - }); - expect(result.replyCount).toBe(0); - expect(result.visibility).toBe('public'); - expect(result.isEdited).toBe(false); - }); - - it('should reject without required fields', () => { - expect(() => FeedItemSchema.parse({})).toThrow(); - expect(() => FeedItemSchema.parse({ id: 'x' })).toThrow(); - expect(() => FeedItemSchema.parse({ id: 'x', type: 'comment' })).toThrow(); - }); - - it('should reject invalid datetime format', () => { - expect(() => FeedItemSchema.parse({ - ...minimalComment, - createdAt: 'not-a-date', - })).toThrow(); - }); -}); diff --git a/packages/spec/src/data/feed.zod.ts b/packages/spec/src/data/feed.zod.ts index 211c8fcf10..cc3688f29e 100644 --- a/packages/spec/src/data/feed.zod.ts +++ b/packages/spec/src/data/feed.zod.ts @@ -2,12 +2,21 @@ import { z } from 'zod'; +/** + * Activity-timeline UI config enums. + * + * The `service-feed` backend was retired (ADR-0052 §5 / #1955); `sys_comment` / + * `sys_activity` are the canonical record-collaboration/timeline backend. Only these + * two enums remain here — they are pure UI configuration for the record activity + * component (`RecordActivityProps` in `../ui/component.zod.ts`), with no backend + * dependency. (A later `feed` → `activity` rename is tracked separately.) + */ + /** * Feed Item Type * Unified activity types for the record timeline. * Covers comments, field changes, tasks, events, and system activities. */ -import { lazySchema } from '../shared/lazy-schema'; export const FeedItemType = z.enum([ 'comment', 'field_change', @@ -25,142 +34,6 @@ export const FeedItemType = z.enum([ ]); export type FeedItemType = z.infer; -/** - * Mention Schema - * Represents an @mention within comment body text. - */ -export const MentionSchema = lazySchema(() => z.object({ - type: z.enum(['user', 'team', 'record']).describe('Mention target type'), - id: z.string().describe('Target ID'), - name: z.string().describe('Display name for rendering'), - offset: z.number().int().min(0).describe('Character offset in body text'), - length: z.number().int().min(1).describe('Length of mention token in body text'), -})); -export type Mention = z.infer; - -/** - * Field Change Entry Schema - * Represents a single field-level change within a field_change feed item. - */ -export const FieldChangeEntrySchema = lazySchema(() => z.object({ - field: z.string().describe('Field machine name'), - fieldLabel: z.string().optional().describe('Field display label'), - oldValue: z.unknown().optional().describe('Previous value'), - newValue: z.unknown().optional().describe('New value'), - oldDisplayValue: z.string().optional().describe('Human-readable old value'), - newDisplayValue: z.string().optional().describe('Human-readable new value'), -})); -export type FieldChangeEntry = z.infer; - -/** - * Reaction Schema - * Represents an emoji reaction on a feed item. - */ -export const ReactionSchema = lazySchema(() => z.object({ - emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'), - userIds: z.array(z.string()).describe('Users who reacted'), - count: z.number().int().min(1).describe('Total reaction count'), -})); -export type Reaction = z.infer; - -/** - * Feed Actor Schema - * Represents the actor who performed the action. - */ -export const FeedActorSchema = lazySchema(() => z.object({ - type: z.enum(['user', 'system', 'service', 'automation']).describe('Actor type'), - id: z.string().describe('Actor ID'), - name: z.string().optional().describe('Actor display name'), - avatarUrl: z.string().url().optional().describe('Actor avatar URL'), - source: z.string().optional().describe('Source application (e.g., "Omni", "API", "Studio")'), -})); -export type FeedActor = z.infer; - -/** - * Feed Item Visibility - */ -export const FeedVisibility = z.enum(['public', 'internal', 'private']); -export type FeedVisibility = z.infer; - -/** - * Feed Item Schema - * A single entry in the unified activity timeline. - * - * @example Comment - * { - * id: 'feed_001', - * type: 'comment', - * object: 'account', - * recordId: 'rec_123', - * body: 'Great progress! @jane.doe can you follow up?', - * mentions: [{ type: 'user', id: 'user_123', name: 'Jane Doe', offset: 17, length: 9 }], - * actor: { type: 'user', id: 'user_456', name: 'John Smith' }, - * createdAt: '2026-01-15T10:30:00Z', - * } - * - * @example Field Change - * { - * id: 'feed_002', - * type: 'field_change', - * object: 'account', - * recordId: 'rec_123', - * changes: [ - * { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' }, - * { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' }, - * ], - * actor: { type: 'user', id: 'user_456', name: 'John Smith' }, - * createdAt: '2026-01-15T10:25:00Z', - * } - */ -export const FeedItemSchema = lazySchema(() => z.object({ - /** Unique identifier */ - id: z.string().describe('Feed item ID'), - - /** Feed item type */ - type: FeedItemType.describe('Activity type'), - - /** Target record reference */ - object: z.string().describe('Object name (e.g., "account")'), - recordId: z.string().describe('Record ID this feed item belongs to'), - - /** Actor (who performed the action) */ - actor: FeedActorSchema.describe('Who performed this action'), - - /** Content (for comments/notes) */ - body: z.string().optional().describe('Rich text body (Markdown supported)'), - - /** @Mentions */ - mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'), - - /** Field changes (for field_change type) */ - changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'), - - /** Reactions */ - reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'), - - /** Reply threading */ - parentId: z.string().optional().describe('Parent feed item ID for threaded replies'), - replyCount: z.number().int().min(0).default(0).describe('Number of replies'), - - /** Pin / Star */ - pinned: z.boolean().default(false).describe('Whether the feed item is pinned to the top of the timeline'), - pinnedAt: z.string().datetime().optional().describe('Timestamp when the item was pinned'), - pinnedBy: z.string().optional().describe('User ID who pinned the item'), - starred: z.boolean().default(false).describe('Whether the feed item is starred/bookmarked by the current user'), - starredAt: z.string().datetime().optional().describe('Timestamp when the item was starred'), - - /** Visibility */ - visibility: FeedVisibility.default('public') - .describe('Visibility: public (all users), internal (team only), private (author + mentioned)'), - - /** Timestamps */ - createdAt: z.string().datetime().describe('Creation timestamp'), - updatedAt: z.string().datetime().optional().describe('Last update timestamp'), - editedAt: z.string().datetime().optional().describe('When comment was last edited'), - isEdited: z.boolean().default(false).describe('Whether comment has been edited'), -})); -export type FeedItem = z.infer; - /** * Feed Filter Mode * Controls which feed item types to display in the timeline. diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 77bd95d165..e2d49b5ea6 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -57,8 +57,6 @@ export * from './field-group-layout'; // create/edit/detail opens by default (full page vs drawer/modal overlay). export * from './record-surface'; -// Feed & Activity Protocol +// Feed & Activity Protocol — retains only the UI activity-timeline config enums +// (FeedItemType / FeedFilterMode); the feed backend contracts were retired (ADR-0052 §5). export * from './feed.zod'; - -// Subscription Protocol -export * from './subscription.zod'; diff --git a/packages/spec/src/data/subscription.test.ts b/packages/spec/src/data/subscription.test.ts deleted file mode 100644 index 05f0dfc2e2..0000000000 --- a/packages/spec/src/data/subscription.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - SubscriptionEventType, - NotificationChannel, - RecordSubscriptionSchema, - type RecordSubscription, -} from './subscription.zod'; - -describe('SubscriptionEventType', () => { - it('should accept all valid event types', () => { - const types = ['comment', 'mention', 'field_change', 'task', 'approval', 'all']; - types.forEach(type => { - expect(() => SubscriptionEventType.parse(type)).not.toThrow(); - }); - }); - - it('should reject invalid event type', () => { - expect(() => SubscriptionEventType.parse('unknown')).toThrow(); - expect(() => SubscriptionEventType.parse('')).toThrow(); - }); -}); - -describe('NotificationChannel', () => { - it('should accept all valid channels', () => { - const channels = ['in_app', 'email', 'push', 'slack']; - channels.forEach(channel => { - expect(() => NotificationChannel.parse(channel)).not.toThrow(); - }); - }); - - it('should reject invalid channel', () => { - expect(() => NotificationChannel.parse('sms')).toThrow(); - }); -}); - -describe('RecordSubscriptionSchema', () => { - const minimalSubscription: RecordSubscription = { - object: 'account', - recordId: 'rec_123', - userId: 'user_456', - createdAt: '2026-01-15T10:00:00Z', - }; - - it('should accept minimal subscription with defaults', () => { - const result = RecordSubscriptionSchema.parse(minimalSubscription); - expect(result.object).toBe('account'); - expect(result.recordId).toBe('rec_123'); - expect(result.userId).toBe('user_456'); - expect(result.events).toEqual(['all']); - expect(result.channels).toEqual(['in_app']); - expect(result.active).toBe(true); - }); - - it('should accept full subscription', () => { - const full: RecordSubscription = { - object: 'opportunity', - recordId: 'rec_789', - userId: 'user_101', - events: ['comment', 'field_change'], - channels: ['in_app', 'email'], - active: true, - createdAt: '2026-01-15T10:00:00Z', - }; - const result = RecordSubscriptionSchema.parse(full); - expect(result.events).toEqual(['comment', 'field_change']); - expect(result.channels).toEqual(['in_app', 'email']); - }); - - it('should accept inactive subscription', () => { - const result = RecordSubscriptionSchema.parse({ - ...minimalSubscription, - active: false, - }); - expect(result.active).toBe(false); - }); - - it('should reject without required fields', () => { - expect(() => RecordSubscriptionSchema.parse({})).toThrow(); - expect(() => RecordSubscriptionSchema.parse({ object: 'account' })).toThrow(); - expect(() => RecordSubscriptionSchema.parse({ object: 'account', recordId: 'rec_1' })).toThrow(); - }); - - it('should reject invalid datetime format', () => { - expect(() => RecordSubscriptionSchema.parse({ - ...minimalSubscription, - createdAt: 'not-a-date', - })).toThrow(); - }); - - it('should reject invalid event types in array', () => { - expect(() => RecordSubscriptionSchema.parse({ - ...minimalSubscription, - events: ['invalid_event'], - })).toThrow(); - }); - - it('should reject invalid notification channels', () => { - expect(() => RecordSubscriptionSchema.parse({ - ...minimalSubscription, - channels: ['sms'], - })).toThrow(); - }); -}); diff --git a/packages/spec/src/data/subscription.zod.ts b/packages/spec/src/data/subscription.zod.ts deleted file mode 100644 index 25395d5882..0000000000 --- a/packages/spec/src/data/subscription.zod.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; - -/** - * Subscription Event Type - * Event types that can be subscribed to for record-level notifications. - */ -import { lazySchema } from '../shared/lazy-schema'; -export const SubscriptionEventType = z.enum([ - 'comment', - 'mention', - 'field_change', - 'task', - 'approval', - 'all', -]); -export type SubscriptionEventType = z.infer; - -/** - * Notification Channel - * Delivery channels for record subscription notifications. - */ -export const NotificationChannel = z.enum([ - 'in_app', - 'email', - 'push', - 'slack', -]); -export type NotificationChannel = z.infer; - -/** - * Record Subscription Schema - * Defines a user's subscription to record-level notifications. - * Enables Airtable-style bell icon for record change notifications. - */ -export const RecordSubscriptionSchema = lazySchema(() => z.object({ - /** Target */ - object: z.string().describe('Object name'), - recordId: z.string().describe('Record ID'), - - /** Subscriber */ - userId: z.string().describe('Subscribing user ID'), - - /** Events to subscribe to */ - events: z.array(SubscriptionEventType) - .default(['all']) - .describe('Event types to receive notifications for'), - - /** Notification channels */ - channels: z.array(NotificationChannel) - .default(['in_app']) - .describe('Notification delivery channels'), - - /** Active */ - active: z.boolean().default(true).describe('Whether the subscription is active'), - - /** Timestamps */ - createdAt: z.string().datetime().describe('Subscription creation timestamp'), -})); -export type RecordSubscription = z.infer; diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index 415d5256b8..2041eee2b8 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -21,7 +21,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies -- `node_modules/@objectstack/spec/src/data/feed.zod.ts` — Feed Item Type +- `node_modules/@objectstack/spec/src/data/feed.zod.ts` — Activity-timeline UI config enums. - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request.