@ai-matrx/chat 0.7.1 → 0.7.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.7.3
4
+
5
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
6
+ `git diff npm/chat/v0.7.2..npm/chat/v0.7.3 -- apps/shared/chat`).
7
+ No source changes intended and no consumer action required.
8
+
9
+ ## 0.7.2
10
+
11
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
12
+ `git diff npm/chat/v0.7.1..npm/chat/v0.7.2 -- apps/shared/chat`).
13
+ No source changes intended and no consumer action required.
14
+
3
15
  ## 0.7.1
4
16
 
5
17
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -8,15 +8,21 @@
8
8
  * (state_version > sent_version, not dismissed at that version) and carries `blockStateRef` so the
9
9
  * server marks it sent with the message. A chip belongs to the conversation it was staged into.
10
10
  * Registered by `<ChatProvider>` for a private store when the host registered no port of its own.
11
- * Not in this default: the live feed from another tab (the next mount lists again).
11
+ * Live: a chip made, sent or dismissed in another tab or on another device lands here without a
12
+ * reload, through the realtime feed (`subscribeFeed`, injectable for tests). Runs on EVERY host.
12
13
  */
13
14
  import type { ChatDb } from "../../../../host/contract.js";
14
15
  import type { ChatDispatch } from "../../../../store/root-state.js";
16
+ import { type RemarkFeedSubscribe } from "./remark-feed.js";
15
17
  import { type RemarkDurability } from "./remarks.js";
16
18
  export interface DefaultRemarkDurabilityOptions {
17
19
  db: ChatDb;
18
20
  dispatch: ChatDispatch;
21
+ /** The host store's state: answers (a conversation with none has no feed), sent chips, the person. */
22
+ getState: () => unknown;
23
+ /** The live feed of a conversation's rows. Default: the realtime broadcast. */
24
+ subscribeFeed?: RemarkFeedSubscribe;
19
25
  /** A write that failed: say so (the host's notify + console). */
20
26
  onFailure: (message: string, error: unknown) => void;
21
27
  }
22
- export declare function createDbRemarkDurability({ db, dispatch, onFailure }: DefaultRemarkDurabilityOptions): RemarkDurability;
28
+ export declare function createDbRemarkDurability({ db, dispatch, getState, subscribeFeed, onFailure }: DefaultRemarkDurabilityOptions): RemarkDurability;
@@ -1,7 +1,10 @@
1
1
  import { durableRecordId } from "@ai-matrx/kit/ids";
2
+ import { selectUserId } from "../../../../host/identity.js";
3
+ import { subscribeRemarkFeed } from "./remark-feed.js";
2
4
  import {
3
5
  attachRemarkRef,
4
6
  readStoredRemarks,
7
+ remarkSourceOf,
5
8
  restageRemarks,
6
9
  unstageRemark
7
10
  } from "./remarks.js";
@@ -35,7 +38,7 @@ function stripRef(item) {
35
38
  const { blockStateRef: _ref, ...rest } = item;
36
39
  return rest;
37
40
  }
38
- function createDbRemarkDurability({ db, dispatch, onFailure }) {
41
+ function createDbRemarkDurability({ db, dispatch, getState, subscribeFeed = subscribeRemarkFeed, onFailure }) {
39
42
  const rpc = (name, args) => db.rpc(name, args);
40
43
  const slots = /* @__PURE__ */ new Map();
41
44
  const restored = /* @__PURE__ */ new Set();
@@ -87,6 +90,52 @@ function createDbRemarkDurability({ db, dispatch, onFailure }) {
87
90
  }
88
91
  });
89
92
  };
93
+ const applyRows = (conversationId, all) => {
94
+ const mine = all.filter((r) => r.kind === "remark" && r.state.stagedIn === conversationId && r.state.remark && typeof r.state.remark === "object");
95
+ const state = getState();
96
+ const sentRefs = /* @__PURE__ */ new Set();
97
+ for (const resourceId of state.instanceResources?.submittedIds?.[conversationId] ?? []) {
98
+ const resource = state.instanceResources?.byConversationId?.[conversationId]?.[resourceId];
99
+ const sentRef = resource ? remarkSourceOf(resource)?.remark.blockStateRef : null;
100
+ if (sentRef) sentRefs.add(`${sentRef.id}:${sentRef.stateVersion}`);
101
+ }
102
+ for (const r of mine) {
103
+ if (!isDue(r) && typeof r.state.coalesceKey === "string") {
104
+ dispatch(unstageRemark(conversationId, r.state.coalesceKey, { retire: false }));
105
+ }
106
+ }
107
+ const stored = readStoredRemarks(
108
+ mine.filter(isDue).filter((r) => !sentRefs.has(`${r.id}:${r.state_version}`)).map((r) => ({
109
+ resourceId: typeof r.state.resourceId === "string" ? r.state.resourceId : r.id,
110
+ coalesceKey: typeof r.state.coalesceKey === "string" ? r.state.coalesceKey : null,
111
+ item: { ...r.state.remark, blockStateRef: { id: r.id, stateVersion: r.state_version } }
112
+ }))
113
+ );
114
+ if (stored.length > 0) dispatch(restageRemarks(conversationId, stored));
115
+ };
116
+ const watching = /* @__PURE__ */ new Map();
117
+ const watch = (conversationId) => {
118
+ if (watching.has(conversationId)) return;
119
+ const answers = getState().messages?.byConversationId?.[conversationId]?.orderedIds?.length ?? 0;
120
+ if (answers === 0) return;
121
+ const userId = selectUserId(getState());
122
+ if (!userId) return;
123
+ const release = subscribeFeed({ scope: "conversation", entityId: conversationId }, userId, (signal) => {
124
+ if ("rows" in signal) {
125
+ applyRows(conversationId, signal.rows);
126
+ } else {
127
+ void (async () => {
128
+ const { data, error } = await rpc("block_state_list_staged", { p_conversation_id: conversationId });
129
+ if (error) return console.error("[remarks] resync failed:", error.message);
130
+ applyRows(conversationId, (Array.isArray(data) ? data : []).flatMap((e) => {
131
+ const r = rowOf(e);
132
+ return r ? [r] : [];
133
+ }));
134
+ })();
135
+ }
136
+ });
137
+ watching.set(conversationId, release);
138
+ };
90
139
  return {
91
140
  save(conversationId, resourceId, coalesceKey, item) {
92
141
  const key = coalesceKey ?? resourceId;
@@ -125,36 +174,30 @@ function createDbRemarkDurability({ db, dispatch, onFailure }) {
125
174
  await Promise.all(slotsOf(conversationId).map(([, slot]) => slot.chain));
126
175
  },
127
176
  restore(conversationId) {
128
- if (restored.has(conversationId) || restoring.has(conversationId)) return;
177
+ if (restored.has(conversationId)) {
178
+ watch(conversationId);
179
+ return;
180
+ }
181
+ if (restoring.has(conversationId)) return;
129
182
  restoring.add(conversationId);
130
183
  void (async () => {
184
+ let rows;
131
185
  try {
132
186
  const { data, error } = await rpc("block_state_list_staged", { p_conversation_id: conversationId });
133
187
  if (error) throw new Error(error.message);
134
- restored.add(conversationId);
135
- const rows = (Array.isArray(data) ? data : []).flatMap((entry) => {
188
+ rows = (Array.isArray(data) ? data : []).flatMap((entry) => {
136
189
  const row = rowOf(entry);
137
190
  return row ? [row] : [];
138
191
  });
139
- const mine = rows.filter((r) => r.kind === "remark" && r.state.stagedIn === conversationId && r.state.remark && typeof r.state.remark === "object");
140
- for (const r of mine) {
141
- if (!isDue(r) && typeof r.state.coalesceKey === "string") {
142
- dispatch(unstageRemark(conversationId, r.state.coalesceKey, { retire: false }));
143
- }
144
- }
145
- const stored = readStoredRemarks(
146
- mine.filter(isDue).map((r) => ({
147
- resourceId: typeof r.state.resourceId === "string" ? r.state.resourceId : r.id,
148
- coalesceKey: typeof r.state.coalesceKey === "string" ? r.state.coalesceKey : null,
149
- item: { ...r.state.remark, blockStateRef: { id: r.id, stateVersion: r.state_version } }
150
- }))
151
- );
152
- if (stored.length > 0) dispatch(restageRemarks(conversationId, stored));
153
192
  } catch (error) {
154
193
  console.error("[remarks] restoring unsent chips failed:", error);
194
+ return;
155
195
  } finally {
156
196
  restoring.delete(conversationId);
157
197
  }
198
+ restored.add(conversationId);
199
+ applyRows(conversationId, rows);
200
+ watch(conversationId);
158
201
  })();
159
202
  }
160
203
  };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * remark-feed — the live feed of one conversation's block-state rows (PRIVATE Database Broadcast
3
+ * topics the block-state door sends on; event `block_state`, payload {op, row}):
4
+ * shared rows block_states:<scope>:<entity id>
5
+ * private rows block_states:<scope>:<entity id>:<user id>
6
+ * `scope` is `conversation` for chat answers. Refcounted: one pair of channels per unit.
7
+ * Namespace specs are identical to the host's own block feed, so both may declare them.
8
+ */
9
+ export interface RemarkFeedRow {
10
+ id: string;
11
+ kind: string;
12
+ state_version: number;
13
+ sent_version: number;
14
+ state: Record<string, unknown>;
15
+ metadata: Record<string, unknown> | null;
16
+ }
17
+ export type RemarkFeedSignal = {
18
+ rows: RemarkFeedRow[];
19
+ } | {
20
+ resync: true;
21
+ };
22
+ export type RemarkFeedSubscribe = (unit: {
23
+ scope: string;
24
+ entityId: string;
25
+ }, userId: string, listener: (signal: RemarkFeedSignal) => void) => () => void;
26
+ export declare const subscribeRemarkFeed: RemarkFeedSubscribe;
@@ -0,0 +1,87 @@
1
+ import { defineChannelNamespace, onRealtimeManagerChange } from "@ai-matrx/realtime";
2
+ const sharedChannel = defineChannelNamespace({
3
+ namespace: "block-states-shared",
4
+ parts: ["scope", "entityId"],
5
+ description: "Block state rows everyone on a record sees (private Database Broadcast)",
6
+ foreignTopic: "block_states"
7
+ });
8
+ const privateChannel = defineChannelNamespace({
9
+ namespace: "block-states-private",
10
+ parts: ["scope", "entityId", "viewerId"],
11
+ description: "One person's own block state rows on a record (private Database Broadcast)",
12
+ foreignTopic: "block_states"
13
+ });
14
+ const entries = /* @__PURE__ */ new Map();
15
+ function rowOf(value) {
16
+ if (!value || typeof value !== "object") return null;
17
+ const r = value;
18
+ if (typeof r.id !== "string" || typeof r.state_version !== "number") return null;
19
+ return {
20
+ id: r.id,
21
+ kind: String(r.kind ?? ""),
22
+ state_version: r.state_version,
23
+ sent_version: typeof r.sent_version === "number" ? r.sent_version : 0,
24
+ state: r.state && typeof r.state === "object" ? r.state : {},
25
+ metadata: r.metadata && typeof r.metadata === "object" ? r.metadata : null
26
+ };
27
+ }
28
+ const subscribeRemarkFeed = (unit, userId, listener) => {
29
+ const id = `${unit.scope}:${unit.entityId}:${userId}`;
30
+ let entry = entries.get(id);
31
+ if (!entry) {
32
+ const created = { listeners: /* @__PURE__ */ new Set(), handles: [], stop: () => {
33
+ } };
34
+ const fan = (signal) => {
35
+ for (const l of Array.from(created.listeners)) l(signal);
36
+ };
37
+ const closeAll = () => {
38
+ for (const h of created.handles) h.close();
39
+ created.handles = [];
40
+ };
41
+ created.stop = onRealtimeManagerChange((manager) => {
42
+ closeAll();
43
+ if (!manager) return;
44
+ const open = (topic) => manager.open({
45
+ topic,
46
+ private: true,
47
+ wire: { mode: "raw", acceptEchoFromSelf: true },
48
+ broadcast: [
49
+ {
50
+ event: "block_state",
51
+ onMessage: ({ data }) => {
52
+ const row = rowOf(data?.row);
53
+ if (row) fan({ rows: [row] });
54
+ }
55
+ }
56
+ ],
57
+ eventKey: (_source, payload) => {
58
+ const frame = payload;
59
+ const row = frame?.row;
60
+ return row?.id === void 0 ? void 0 : `${String(row.id)}:${String(row.version)}:${String(row.state_version)}:${String(row.sent_version)}:${frame?.op ?? ""}`;
61
+ },
62
+ // Realtime has no replay: whatever happened while the socket was away is re-read.
63
+ onBackfill: () => fan({ resync: true })
64
+ });
65
+ created.handles = [
66
+ open(sharedChannel.topic({ scope: unit.scope, entityId: unit.entityId })),
67
+ open(privateChannel.topic({ scope: unit.scope, entityId: unit.entityId, viewerId: userId }))
68
+ ];
69
+ });
70
+ entries.set(id, created);
71
+ entry = created;
72
+ }
73
+ entry.listeners.add(listener);
74
+ return () => {
75
+ const current = entries.get(id);
76
+ if (!current) return;
77
+ current.listeners.delete(listener);
78
+ if (current.listeners.size === 0) {
79
+ current.stop();
80
+ for (const h of current.handles) h.close();
81
+ entries.delete(id);
82
+ }
83
+ };
84
+ };
85
+ export {
86
+ subscribeRemarkFeed
87
+ };
@@ -157,6 +157,16 @@ export interface RemarkDurability {
157
157
  /** Write everything queued NOW and wait until every chip holds its saved row/ref. */
158
158
  flush(conversationId: string): Promise<void>;
159
159
  }
160
+ /**
161
+ * Other writers whose saved state a send must wait for (a mounted interactive block's queued
162
+ * answers). They join the chips' flush: one call writes everything for the conversation.
163
+ */
164
+ export interface PendingWritesPort {
165
+ hasPending(conversationId: string): boolean;
166
+ flush(conversationId: string): Promise<void>;
167
+ }
168
+ /** Join the send's flush. Returns the release. */
169
+ export declare function registerPendingWritesPort(port: PendingWritesPort): () => void;
160
170
  /** True while some chip of this conversation has not yet been saved server-side. */
161
171
  export declare function hasPendingRemarkWrites(conversationId: string): boolean;
162
172
  /**
@@ -69,8 +69,15 @@ function buildSource(item, coalesceKey) {
69
69
  text: remarkReadableText(item)
70
70
  };
71
71
  }
72
+ const pendingWritePorts = /* @__PURE__ */ new Set();
73
+ function registerPendingWritesPort(port) {
74
+ pendingWritePorts.add(port);
75
+ return () => {
76
+ pendingWritePorts.delete(port);
77
+ };
78
+ }
72
79
  function hasPendingRemarkWrites(conversationId) {
73
- return durability?.hasPending(conversationId) ?? false;
80
+ return (durability?.hasPending(conversationId) ?? false) || [...pendingWritePorts].some((p) => p.hasPending(conversationId));
74
81
  }
75
82
  function isRemarkDurabilityRegistered() {
76
83
  return durability !== null;
@@ -80,12 +87,12 @@ function announceMissingDurability(op) {
80
87
  if (announcedMissing.has(op)) return;
81
88
  announcedMissing.add(op);
82
89
  console.error(
83
- `[remarks] ${op} ran with no durability port registered: unsent chips are NOT kept server-side. The app must call registerRemarkDurability (providers/ChatSurfaceRegistrations).`
90
+ `[remarks] ${op} ran with no durability port registered: unsent chips are NOT kept server-side. <ChatProvider> registers the default; a host that replaced it must keep one registered.`
84
91
  );
85
92
  }
86
93
  async function flushRemarkWrites(conversationId) {
87
94
  if (!durability) announceMissingDurability("flush");
88
- await durability?.flush(conversationId);
95
+ await Promise.all([durability?.flush(conversationId), ...[...pendingWritePorts].map((p) => p.flush(conversationId))]);
89
96
  }
90
97
  function restoreComposerRemarks(conversationId) {
91
98
  if (!durability) announceMissingDurability("restore");
@@ -222,6 +229,7 @@ export {
222
229
  hasPendingRemarkWrites,
223
230
  isRemarkDurabilityRegistered,
224
231
  readStoredRemarks,
232
+ registerPendingWritesPort,
225
233
  registerRemarkDurability,
226
234
  remarkChipTitle,
227
235
  remarkKindLabel,