@antzsoft/chat-core 1.2.6 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1701,6 +1701,173 @@ const config = await appConfigApi.get();
1701
1701
 
1702
1702
  ---
1703
1703
 
1704
+ ### Sync API (`syncApi`)
1705
+
1706
+ > **v1.2.7+** — Mobile (React Native) only. Web SDK uses React Query cache invalidation on reconnect — no sync calls needed.
1707
+
1708
+ ```typescript
1709
+ import { syncApi } from '@antzsoft/chat-core';
1710
+ import type {
1711
+ CrossConversationSyncResponse,
1712
+ ConversationSyncResponse,
1713
+ } from '@antzsoft/chat-core';
1714
+ ```
1715
+
1716
+ | Method | Signature | Description |
1717
+ |---|---|---|
1718
+ | `pull` | `(since: string) => Promise<CrossConversationSyncResponse>` | Cross-conversation delta sync. Call on every socket reconnect. `since` is the `syncedAt` value returned by the previous call (server clock — avoids client clock skew). |
1719
+ | `pullConversation` | `(conversationId: string, since?: string) => Promise<ConversationSyncResponse>` | Per-conversation delta sync. Call when a conversation has `needs_refresh = true`, or omit `since` on fresh install to receive full history. |
1720
+
1721
+ #### When to call each
1722
+
1723
+ | Situation | Call |
1724
+ |---|---|
1725
+ | Socket reconnects (within stale threshold) | `syncApi.pull(lastSyncedAt)` — returns all cross-conversation changes |
1726
+ | Socket reconnects (gap > stale threshold) | `pull()` returns `stale: true` — mark all conversations `needs_refresh`, lazy-sync on open |
1727
+ | User opens a stale conversation | `syncApi.pullConversation(convId, lastSyncedAt)` — returns full delta for that conversation |
1728
+ | Fresh install / first open | `syncApi.pullConversation(convId)` — omit `since` to receive full history |
1729
+
1730
+ #### `CrossConversationSyncResponse`
1731
+
1732
+ Returned by `syncApi.pull(since)`. Does **not** include reactions or stars — those are per-conversation and returned by `pullConversation`.
1733
+
1734
+ ```typescript
1735
+ interface CrossConversationSyncResponse {
1736
+ syncedAt: string; // server-stamped ISO timestamp — store as new lastSyncedAt
1737
+ stale: boolean; // true when gap > CHAT_SYNC_STALE_DAYS (default 30) — app should lazy-sync on open
1738
+ messages: Message[]; // edits, deletes, pins, unpins — any message with updatedAt > since
1739
+ deletedForMe: SyncDeletedForMe[];
1740
+ participantChanges: SyncParticipantChange[];
1741
+ readReceipts: SyncReadReceipt[];
1742
+ }
1743
+
1744
+ interface SyncDeletedForMe {
1745
+ messageId: string;
1746
+ conversationId: string;
1747
+ deletedAt: string | null;
1748
+ }
1749
+
1750
+ interface SyncParticipantChange {
1751
+ conversationId: string;
1752
+ userId: string;
1753
+ role: string;
1754
+ isActive: boolean; // false = removed
1755
+ isMuted: boolean;
1756
+ mutedUntil: string | null;
1757
+ updatedAt: string | null;
1758
+ }
1759
+
1760
+ interface SyncReadReceipt {
1761
+ messageId: string;
1762
+ conversationId: string;
1763
+ userId: string;
1764
+ readAt: string | null;
1765
+ }
1766
+ ```
1767
+
1768
+ #### `ConversationSyncResponse`
1769
+
1770
+ Returned by `syncApi.pullConversation(convId, since?)`. Includes everything needed to make local SQLite authoritative for that conversation.
1771
+
1772
+ ```typescript
1773
+ interface ConversationSyncResponse {
1774
+ syncedAt: string;
1775
+ messages: Message[]; // updatedAt > since — edits, deletes, pins
1776
+ deletedForMe: SyncDeletedForMe[];
1777
+ reactions: SyncReactions; // full current state for messages with reactedAt > since
1778
+ stars: SyncStarEntry[]; // updatedAt > since; isActive: false = unstarred
1779
+ participantChanges: SyncParticipantChange[];
1780
+ readReceipts: SyncReadReceipt[];
1781
+ }
1782
+
1783
+ // keyed by messageId — full current emoji counts (not delta)
1784
+ type SyncReactions = Record<string, MessageReaction[]>;
1785
+
1786
+ interface SyncStarEntry {
1787
+ messageId: string;
1788
+ conversationId: string;
1789
+ isActive: boolean; // true = starred, false = unstarred (soft-deleted)
1790
+ updatedAt: string | null;
1791
+ }
1792
+ ```
1793
+
1794
+ #### What each field covers
1795
+
1796
+ | Field | What changed events it captures |
1797
+ |---|---|
1798
+ | `messages` | Edits (`isEdited: true`), deletes (`status: "deleted"`), pins / unpins (`isPinned`) |
1799
+ | `deletedForMe` | Messages this user hid for themselves only — remove from local DB |
1800
+ | `reactions` | Full current emoji counts for any message that had reaction activity. Upsert as ground truth — never merge |
1801
+ | `stars` | `isActive: true` = starred, `isActive: false` = unstarred — delete local star record when false |
1802
+ | `participantChanges` | Role changes, mute/unmute, removals (`isActive: false`) |
1803
+ | `readReceipts` | Read receipts received since `since` |
1804
+
1805
+ #### Stale handling
1806
+
1807
+ When `pull()` returns `stale: true`, the server returns no data — it would be too large to return everything. The stale threshold defaults to **30 days** and is controlled by the `CHAT_SYNC_STALE_DAYS` server environment variable. The correct pattern:
1808
+
1809
+ ```typescript
1810
+ const result = await syncApi.pull(lastSyncedAt);
1811
+
1812
+ if (result.stale) {
1813
+ // Store new cursor so next reconnect uses the server's timestamp, not the old one
1814
+ await AsyncStorage.setItem('@antz_last_synced_at', result.syncedAt);
1815
+ // Mark every conversation needs_refresh in your local SQLite
1816
+ await db.execute('UPDATE conversations SET needs_refresh = 1');
1817
+ // Lazy-sync — call pullConversation when the user actually opens each conversation
1818
+ } else {
1819
+ await AsyncStorage.setItem('@antz_last_synced_at', result.syncedAt);
1820
+ await mergeSyncDeltaIntoSQLite(result);
1821
+ }
1822
+ ```
1823
+
1824
+ #### RN SDK — built-in hooks
1825
+
1826
+ When using `@antzsoft/chat-rn-sdk`, use the pre-built hooks instead of calling `syncApi` directly:
1827
+
1828
+ ```typescript
1829
+ import { useSync, useConversationSync } from '@antzsoft/chat-rn-sdk';
1830
+
1831
+ // In your root provider — auto-triggers on every socket reconnect
1832
+ useSync({
1833
+ onSync: async (data) => {
1834
+ // data is CrossConversationSyncResponse
1835
+ await mergeSyncDeltaIntoSQLite(data);
1836
+ },
1837
+ onStale: async () => {
1838
+ // Gap > stale threshold — mark all conversations needs_refresh
1839
+ await db.execute('UPDATE conversations SET needs_refresh = 1');
1840
+ },
1841
+ });
1842
+
1843
+ // In your conversation screen — call when needs_refresh is true
1844
+ const { isSyncing, sync } = useConversationSync(conversationId);
1845
+
1846
+ useEffect(() => {
1847
+ if (needsRefresh) {
1848
+ sync().then((data) => {
1849
+ if (data) upsertConversationDeltaToSQLite(data);
1850
+ clearNeedsRefresh(conversationId);
1851
+ });
1852
+ }
1853
+ }, [conversationId, needsRefresh]);
1854
+ ```
1855
+
1856
+ #### Full event coverage
1857
+
1858
+ | Event | `pull()` | `pullConversation()` |
1859
+ |---|---|---|
1860
+ | Message edited | ✅ | ✅ |
1861
+ | Message deleted (everyone) | ✅ | ✅ |
1862
+ | Message deleted for me | ✅ | ✅ |
1863
+ | Message pinned / unpinned | ✅ | ✅ |
1864
+ | Reaction added / removed | — | ✅ full current state |
1865
+ | Star / unstar | — | ✅ with `isActive` |
1866
+ | Read receipt | ✅ | ✅ |
1867
+ | Role change / mute / removal | ✅ | ✅ |
1868
+
1869
+ ---
1870
+
1704
1871
  ## Error Handling
1705
1872
 
1706
1873
  All SDK errors — whether from REST calls, socket operations, or internal state — are instances of `AntzChatError` or one of its subclasses. Every error carries a machine-readable `code`, a human-readable `message`, a `retryable` boolean, and an optional `context` object with extra diagnostics.
@@ -2050,6 +2217,7 @@ Subscribe using `client.socket.on(event, handler)` (headless) or directly on the
2050
2217
  | `message_deleted` | `MessageDeletedEvent` | A message was deleted for everyone. | **Chat detail screen** — add on mount, remove on unmount. |
2051
2218
  | `message_deleted_for_me` | `{ messageId: string; conversationId: string }` | A message was hidden for the current user only (fired only to that user's socket). | **Chat detail screen** — add on mount, remove on unmount. |
2052
2219
  | `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). | **Chat detail screen** — add on mount, remove on unmount. |
2220
+ | `message_star_updated` | `MessageStarUpdatedEvent` | A message was starred or unstarred by the current user on another device. Fired only to the acting user's personal room — other participants never receive it. `isStarred: true` = starred, `isStarred: false` = unstarred. | **App root** (to sync star state across all open chat screens and the starred messages list). Keep for full session. |
2053
2221
  | `message_pin_updated` | `{ messageId: string; conversationId: string; isPinned: boolean; pinnedBy?: string; pinnedAt?: string }` | A message was pinned or unpinned. | **Chat detail screen** — add on mount, remove on unmount. |
2054
2222
  | `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. | **Chat detail screen** — add on mount, remove on unmount. |
2055
2223
  | `user_online` | `{ userId: string }` | A participant came online — auto-updates `useChatStore.onlineUsers`. | **App root** — drives online indicators everywhere. Keep for full session. |
@@ -2063,7 +2231,7 @@ Subscribe using `client.socket.on(event, handler)` (headless) or directly on the
2063
2231
  | `messages_delivered` | `MessagesDeliveredEvent` | Batch delivery catch-up — fired to the sender when an offline recipient reconnects and all pending undelivered messages are marked delivered in bulk. Payload: `{ conversationId, messageIds[], deliveredTo: string, deliveredAt }` | **Chat detail screen** — add on mount, remove on unmount. |
2064
2232
  | `conversation_created` | `Conversation` | A new conversation was created (or you were added to one). | **App root** — call `joinRoom` here for the new conversation. Keep for full session. |
2065
2233
  | `conversation_updated` | `Conversation` | A conversation's last message or metadata changed — use this to update the conversation list. | **App root** — keep for full session. The server emits this for every message across all conversations; a global listener keeps the in-memory conversation list and unread badge always in sync. |
2066
- | `conversation_deleted` | `{ conversationId: string }` | A conversation was deleted. | **App root / conversation list screen** — remove from state and navigate away if it was open. |
2234
+ | `conversation_deleted` | `{ conversationId: string }` | Emitted **only to the acting user's own sockets** when they: (1) self-delete/hide a conversation via `delete()`, (2) exit+delete a group via `leave(id, true)`, or (3) hide an already-exited group via `delete()`. Other participants never receive this event. | **App root / conversation list screen** — remove from state and navigate away if it was open. |
2067
2235
  | `participant_joined` | `{ conversationId: string; userId: string; displayName: string; addedBy: string }` | A new participant was added to a group conversation. | **Chat detail screen** — add on mount, remove on unmount. |
2068
2236
  | `participant_left` | `{ conversationId: string; userId: string; displayName: string; removedBy?: string }` | A participant left or was removed from a group conversation. | **Chat detail screen** — add on mount, remove on unmount. |
2069
2237
 
@@ -2073,6 +2241,7 @@ import type {
2073
2241
  MessageUpdatedEvent,
2074
2242
  MessageDeletedEvent,
2075
2243
  ReactionUpdatedEvent,
2244
+ MessageStarUpdatedEvent,
2076
2245
  TypingIndicatorEvent,
2077
2246
  UserStatusEvent,
2078
2247
  ReadReceiptEvent,
@@ -2096,6 +2265,11 @@ client.socket.on('reaction_updated', (evt: ReactionUpdatedEvent) => {
2096
2265
  setMessageReactions(evt.messageId, evt.reactions);
2097
2266
  });
2098
2267
 
2268
+ client.socket.on('message_star_updated', (evt: MessageStarUpdatedEvent) => {
2269
+ // Fires on your other devices when you star/unstar on one device
2270
+ updateMessageStarState(evt.messageId, evt.isStarred);
2271
+ });
2272
+
2099
2273
  client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
2100
2274
  if (evt.isTyping) {
2101
2275
  showTyping(evt.conversationId, evt.displayName);
@@ -2776,6 +2950,16 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2776
2950
 
2777
2951
  ## Changelog
2778
2952
 
2953
+ ### v1.2.7
2954
+ - **New: `syncApi` — offline delta sync for mobile** — Two new REST endpoints let the mobile app pull all changes since a given timestamp without a full refetch. Call `syncApi.pull(since)` on every socket reconnect to get cross-conversation deltas; call `syncApi.pullConversation(convId, since?)` when opening a stale conversation (or on fresh install with no `since`). When the gap exceeds the stale threshold (default 30 days, env: `CHAT_SYNC_STALE_DAYS`) the server sets `stale: true` and returns no data — the app lazy-syncs each conversation as the user opens it. See [`syncApi`](#sync-api-syncapi) for full reference.
2955
+ - **New: `message_star_updated` socket event — real-time star sync across devices** — When the current user stars or unstars a message, the server now emits `message_star_updated` to their personal socket room. All other connected devices of the same user receive the event and update their local message cache automatically — no refetch needed. Payload: `{ messageId: string; conversationId: string; isStarred: boolean }`. New exported type: `MessageStarUpdatedEvent`. Web and RN `SocketProvider` handle this event internally.
2956
+ - **New: Soft-delete for unstar — offline sync support** — Unstar is no longer a hard delete on `chat_message_stars`. The record is now soft-deleted (`isActive: false`, `unstarredAt` timestamp set) so delta sync queries (`updatedAt > since`) can detect both star and unstar events. All `getStarredMessages` queries filter `isActive: true` — no visible behaviour change.
2957
+ - **New: `updatedAt` on `chat_conversation_participants`** — Participant documents now carry `updatedAt` (Mongoose timestamps). Enables delta sync queries to detect mute, pin, role, and removal changes since a given timestamp.
2958
+ - **New: Sync indexes** — Five new MongoDB indexes added: `{ conversationId, updatedAt }` on `chat_messages`, `{ userId, tenantId, updatedAt }` on `chat_conversation_participants`, `{ userId, tenantId, updatedAt }` on `chat_message_stars`, `{ conversationId, reactedAt }` on `chat_message_reactions`, `{ conversationId, readAt }` on `chat_message_reads`. Added idempotently at startup — no manual migration needed.
2959
+ - **Migrations 003 & 004** — `003_backfill_participant_updatedAt`: sets `updatedAt = joinedAt` on all existing participant documents. `004_backfill_star_isActive`: sets `isActive: true` on all existing star records. Both run automatically on first deploy and are idempotent.
2960
+ - **New exported types:** `CrossConversationSyncResponse`, `ConversationSyncResponse`, `SyncReadReceipt`, `SyncDeletedForMe`, `SyncParticipantChange`, `SyncStarEntry`, `SyncReactions`.
2961
+ - **No breaking changes** — all additions are purely additive. Existing API contracts, socket events, and type shapes are unchanged.
2962
+
2779
2963
  ### v1.2.6
2780
2964
 
2781
2965
  - **New: Clear / Delete Chat — hide any conversation without affecting other participants** — `conversationsApi.delete(conversationId)` is now available to any participant (no admin role required) on both DMs and groups. It hides the conversation from the caller's list only; all other participants are unaffected.
package/dist/index.cjs CHANGED
@@ -142,6 +142,7 @@ __export(src_exports, {
142
142
  setTransitSession: () => setTransitSession,
143
143
  socketEmit: () => socketEmit,
144
144
  storageApi: () => storageApi,
145
+ syncApi: () => syncApi,
145
146
  tryGetSocket: () => tryGetSocket,
146
147
  uploadBatch: () => uploadBatch,
147
148
  useChatStore: () => useChatStore,
@@ -840,6 +841,30 @@ var authApi = {
840
841
  }
841
842
  };
842
843
 
844
+ // src/api/sync.ts
845
+ var syncApi = {
846
+ /**
847
+ * Cross-conversation delta sync — call on every socket reconnect.
848
+ * `since` is the ISO timestamp returned as `syncedAt` from the previous call.
849
+ * Returns `stale: true` (with empty arrays) when the gap exceeds 60 days —
850
+ * the app should mark all conversations `needs_refresh` and lazy-sync on open.
851
+ */
852
+ pull(since) {
853
+ return getApiClient().get("/sync", { params: { since } }).then((r) => r.data);
854
+ },
855
+ /**
856
+ * Per-conversation delta sync — call when a conversation has `needs_refresh = true`
857
+ * or on fresh install (omit `since` to receive the full message history).
858
+ * Covers messages, deletedForMe, full reaction state, stars, participant changes,
859
+ * and read receipts — everything needed to make local SQLite authoritative.
860
+ */
861
+ pullConversation(conversationId, since) {
862
+ return getApiClient().get(`/conversations/${conversationId}/sync`, {
863
+ params: since ? { since } : {}
864
+ }).then((r) => r.data);
865
+ }
866
+ };
867
+
843
868
  // src/api/app-config.ts
844
869
  var appConfigApi = {
845
870
  async get() {
@@ -1974,6 +1999,7 @@ var AntzChatClient = class {
1974
1999
  setTransitSession,
1975
2000
  socketEmit,
1976
2001
  storageApi,
2002
+ syncApi,
1977
2003
  tryGetSocket,
1978
2004
  uploadBatch,
1979
2005
  useChatStore,