@abloatai/ablo 0.3.0

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.
Files changed (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Transport-driven IntentStream factory.
3
+ *
4
+ * Mirrors `createPresenceStream` — built directly on `SyncWebSocket`,
5
+ * no SyncAgent wrapper. Intents derive their `others` view from the
6
+ * same `presence_update` frames the presence stream consumes (the
7
+ * Hub piggybacks `activeIntents` on every presence frame). Outbound
8
+ * announce/revoke ride the same socket via `intent_begin` /
9
+ * `intent_abandon` frames.
10
+ *
11
+ * Wire contract (apps/sync-server/src/hub/types.ts):
12
+ * • Outbound: `{ type: 'intent_begin', payload: { intentId,
13
+ * entityType, entityId, action, field?, estimatedMs? } }`
14
+ * • Outbound: `{ type: 'intent_abandon', payload: { intentId } }`
15
+ * • Inbound (via presence): `event.activeIntents: IntentClaim[]`
16
+ * stamped with `declaredAt`, `expiresAt`.
17
+ * • Inbound: `intent_rejected` event with conflict metadata.
18
+ *
19
+ * After the dual-engine collapse (step #36), this is the only
20
+ * IntentStream factory in the SDK; the older compatibility path
21
+ * deletes.
22
+ */
23
+ import type { SyncWebSocket } from './SyncWebSocket.js';
24
+ import type { IntentStream } from '../types/streams.js';
25
+ export interface IntentStreamConfig {
26
+ /** Identity used to filter our own active intents out of `others`. */
27
+ participantId: string;
28
+ }
29
+ export interface AttachableIntentStream extends IntentStream {
30
+ attach(transport: SyncWebSocket): void;
31
+ dispose(): void;
32
+ }
33
+ export declare function createIntentStream(config: IntentStreamConfig, transport?: SyncWebSocket | null): AttachableIntentStream;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Transport-driven IntentStream factory.
3
+ *
4
+ * Mirrors `createPresenceStream` — built directly on `SyncWebSocket`,
5
+ * no SyncAgent wrapper. Intents derive their `others` view from the
6
+ * same `presence_update` frames the presence stream consumes (the
7
+ * Hub piggybacks `activeIntents` on every presence frame). Outbound
8
+ * announce/revoke ride the same socket via `intent_begin` /
9
+ * `intent_abandon` frames.
10
+ *
11
+ * Wire contract (apps/sync-server/src/hub/types.ts):
12
+ * • Outbound: `{ type: 'intent_begin', payload: { intentId,
13
+ * entityType, entityId, action, field?, estimatedMs? } }`
14
+ * • Outbound: `{ type: 'intent_abandon', payload: { intentId } }`
15
+ * • Inbound (via presence): `event.activeIntents: IntentClaim[]`
16
+ * stamped with `declaredAt`, `expiresAt`.
17
+ * • Inbound: `intent_rejected` event with conflict metadata.
18
+ *
19
+ * After the dual-engine collapse (step #36), this is the only
20
+ * IntentStream factory in the SDK; the older compatibility path
21
+ * deletes.
22
+ */
23
+ import { asyncIteratorFrom } from '../utils/asyncIterator.js';
24
+ import { toMs } from '../utils/duration.js';
25
+ export function createIntentStream(config, transport = null) {
26
+ const { participantId } = config;
27
+ // ── State: others' open intents, keyed by intentId ───────────────
28
+ const activeByIntentId = new Map();
29
+ let intentsSnapshot = Object.freeze([]);
30
+ // ── State: our own open intents (for re-announce on reconnect) ───
31
+ const ownIntents = new Map();
32
+ // ── Subscribers ──────────────────────────────────────────────────
33
+ const listeners = new Set();
34
+ const rejectionListeners = new Set();
35
+ const notifyListeners = () => {
36
+ intentsSnapshot = Object.freeze(Array.from(activeByIntentId.values()));
37
+ for (const l of listeners) {
38
+ try {
39
+ l();
40
+ }
41
+ catch {
42
+ /* listener errors don't break siblings */
43
+ }
44
+ }
45
+ };
46
+ // ── Wire wiring ──────────────────────────────────────────────────
47
+ let attached = null;
48
+ const unsubs = [];
49
+ function attach(t) {
50
+ if (attached)
51
+ return;
52
+ attached = t;
53
+ // (1) Inbound presence frames carry every participant's full
54
+ // active-intent set. Prune previous claims by holder, then
55
+ // re-add from the frame — the frame is authoritative for that
56
+ // participant's open intents at that moment.
57
+ unsubs.push(t.subscribe('presence_update', (event) => {
58
+ if (!event.userId)
59
+ return;
60
+ if (event.userId === participantId)
61
+ return;
62
+ let mutated = false;
63
+ if (event.kind === 'leave') {
64
+ for (const [id, intent] of activeByIntentId) {
65
+ if (intent.heldBy === event.userId) {
66
+ activeByIntentId.delete(id);
67
+ mutated = true;
68
+ }
69
+ }
70
+ if (mutated)
71
+ notifyListeners();
72
+ return;
73
+ }
74
+ for (const [id, intent] of activeByIntentId) {
75
+ if (intent.heldBy === event.userId) {
76
+ activeByIntentId.delete(id);
77
+ mutated = true;
78
+ }
79
+ }
80
+ for (const claim of event.activeIntents ?? []) {
81
+ activeByIntentId.set(claim.intentId, {
82
+ id: claim.intentId,
83
+ heldBy: event.userId,
84
+ participantKind: event.isAgent ? 'agent' : 'human',
85
+ target: {
86
+ type: claim.entityType,
87
+ id: claim.entityId,
88
+ path: claim.path,
89
+ range: claim.range,
90
+ field: claim.field,
91
+ meta: claim.meta,
92
+ },
93
+ reason: claim.action,
94
+ ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
95
+ announcedAt: new Date(claim.declaredAt).toISOString(),
96
+ expiresAt: new Date(claim.expiresAt).toISOString(),
97
+ });
98
+ mutated = true;
99
+ }
100
+ if (mutated)
101
+ notifyListeners();
102
+ }));
103
+ // (2) Server-side rejection frames.
104
+ unsubs.push(t.subscribe('intent_rejected', (payload) => {
105
+ const rejection = payload;
106
+ if (!rejection.intentId)
107
+ return;
108
+ // Drop the rejected own-claim so reconnect doesn't re-announce
109
+ // a claim the server already rejected (would just spam both
110
+ // sides with conflicts).
111
+ ownIntents.delete(rejection.intentId);
112
+ for (const l of rejectionListeners) {
113
+ try {
114
+ l(rejection);
115
+ }
116
+ catch {
117
+ /* isolate */
118
+ }
119
+ }
120
+ }));
121
+ // (3) On reconnect, re-announce every open self-claim — the
122
+ // server's intent state is in-memory and is lost across
123
+ // restarts. Without this, peers would see our claims vanish
124
+ // whenever the connection blipped.
125
+ unsubs.push(t.subscribe('connected', () => {
126
+ for (const [intentId, intent] of ownIntents) {
127
+ sendBegin(intentId, intent);
128
+ }
129
+ }));
130
+ }
131
+ if (transport)
132
+ attach(transport);
133
+ // ── Outbound ────────────────────────────────────────────────────
134
+ function sendBegin(intentId, intent) {
135
+ if (!attached?.isConnected())
136
+ return;
137
+ attached.send({
138
+ type: 'intent_begin',
139
+ payload: {
140
+ intentId,
141
+ entityType: intent.entityType,
142
+ entityId: intent.entityId,
143
+ path: intent.path,
144
+ range: intent.range,
145
+ action: intent.action,
146
+ field: intent.field,
147
+ meta: intent.meta,
148
+ estimatedMs: intent.estimatedMs,
149
+ },
150
+ });
151
+ }
152
+ function sendAbandon(intentId) {
153
+ if (!attached?.isConnected())
154
+ return;
155
+ attached.send({ type: 'intent_abandon', payload: { intentId } });
156
+ }
157
+ function mintHandle(args) {
158
+ const intentId = crypto.randomUUID();
159
+ const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
160
+ const intent = {
161
+ entityType: args.entityType,
162
+ entityId: args.entityId,
163
+ path: args.path,
164
+ range: args.range,
165
+ field: args.field,
166
+ meta: args.meta,
167
+ action: args.action,
168
+ estimatedMs,
169
+ };
170
+ ownIntents.set(intentId, intent);
171
+ sendBegin(intentId, intent);
172
+ let revoked = false;
173
+ const revoke = () => {
174
+ if (revoked)
175
+ return;
176
+ revoked = true;
177
+ ownIntents.delete(intentId);
178
+ sendAbandon(intentId);
179
+ };
180
+ return {
181
+ id: intentId,
182
+ revoke,
183
+ [Symbol.asyncDispose]: async () => {
184
+ revoke();
185
+ },
186
+ };
187
+ }
188
+ function resolveTarget(target) {
189
+ if (Array.isArray(target))
190
+ return { type: target[0], id: target[1] };
191
+ return target;
192
+ }
193
+ return {
194
+ claim(target, opts) {
195
+ const resolved = resolveTarget(target);
196
+ return mintHandle({
197
+ entityType: resolved.type,
198
+ entityId: resolved.id,
199
+ path: resolved.path,
200
+ range: resolved.range,
201
+ field: resolved.field,
202
+ meta: resolved.meta,
203
+ action: opts?.reason ?? 'editing',
204
+ ttl: opts?.ttl,
205
+ });
206
+ },
207
+ get others() {
208
+ return intentsSnapshot;
209
+ },
210
+ subscribe: (listener) => {
211
+ listeners.add(listener);
212
+ return () => {
213
+ listeners.delete(listener);
214
+ };
215
+ },
216
+ onRejected: (listener) => {
217
+ rejectionListeners.add(listener);
218
+ return () => {
219
+ rejectionListeners.delete(listener);
220
+ };
221
+ },
222
+ [Symbol.asyncIterator]() {
223
+ return asyncIteratorFrom((onChange) => {
224
+ listeners.add(onChange);
225
+ return () => {
226
+ listeners.delete(onChange);
227
+ };
228
+ }, () => intentsSnapshot);
229
+ },
230
+ attach,
231
+ dispose() {
232
+ for (const off of unsubs)
233
+ off();
234
+ unsubs.length = 0;
235
+ listeners.clear();
236
+ rejectionListeners.clear();
237
+ activeByIntentId.clear();
238
+ ownIntents.clear();
239
+ intentsSnapshot = Object.freeze([]);
240
+ attached = null;
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Transport-driven PresenceStream factory.
3
+ *
4
+ * This is the engine's home for presence — built directly on
5
+ * `SyncWebSocket`, no SyncAgent wrapper, no second connection. The
6
+ * older compatibility path predates this and will be deleted when
7
+ * the dual-engine collapse completes.
8
+ *
9
+ * Two construction modes:
10
+ *
11
+ * 1. Direct — pass `transport: SyncWebSocket` when it's already
12
+ * open (agent worker, tests).
13
+ * 2. Deferred — pass `attachLater: true` and call `.attach(transport)`
14
+ * once the engine's WS lifecycle has produced one. The returned
15
+ * stream object is stable from construction; attachment can
16
+ * happen later without callers having to re-grab the reference.
17
+ *
18
+ * Wire contract (apps/sync-server/src/hub/types.ts):
19
+ * • Outbound: `{ type: 'presence_update', payload: { status, activity? } }`
20
+ * — server stamps `userId`, `kind`, `timestamp`, `isAgent` and
21
+ * broadcasts to other clients on the same sync groups.
22
+ * • Inbound: same frame, with `kind: 'enter' | 'update' | 'leave'`.
23
+ */
24
+ import type { SyncWebSocket } from './SyncWebSocket.js';
25
+ import type { PresenceStream } from '../types/streams.js';
26
+ export interface PresenceStreamConfig {
27
+ /** Identity used to filter our own echoed frames out of `others`. */
28
+ participantId: string;
29
+ /** Optional human label for the self entry. */
30
+ label?: string;
31
+ /** Sync groups the participant is broadcasting on. Used for the
32
+ * initial `self` entry and for `othersIn(...)` filtering. */
33
+ syncGroups: readonly string[];
34
+ /** Marks `self` as an agent. Server is the source of truth for
35
+ * peers' `isAgent`, but `self` is local — caller decides. */
36
+ isAgent?: boolean;
37
+ }
38
+ /** PresenceStream extended with engine-lifecycle hooks. */
39
+ export interface AttachablePresenceStream extends PresenceStream {
40
+ /** Wire the stream to a now-ready transport. Calls before this are
41
+ * buffered (self mutations only — no wire send). Idempotent. */
42
+ attach(transport: SyncWebSocket): void;
43
+ /** Tear down listeners. Stream object stays usable as a no-op. */
44
+ dispose(): void;
45
+ }
46
+ export declare function createPresenceStream(config: PresenceStreamConfig, transport?: SyncWebSocket | null): AttachablePresenceStream;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Transport-driven PresenceStream factory.
3
+ *
4
+ * This is the engine's home for presence — built directly on
5
+ * `SyncWebSocket`, no SyncAgent wrapper, no second connection. The
6
+ * older compatibility path predates this and will be deleted when
7
+ * the dual-engine collapse completes.
8
+ *
9
+ * Two construction modes:
10
+ *
11
+ * 1. Direct — pass `transport: SyncWebSocket` when it's already
12
+ * open (agent worker, tests).
13
+ * 2. Deferred — pass `attachLater: true` and call `.attach(transport)`
14
+ * once the engine's WS lifecycle has produced one. The returned
15
+ * stream object is stable from construction; attachment can
16
+ * happen later without callers having to re-grab the reference.
17
+ *
18
+ * Wire contract (apps/sync-server/src/hub/types.ts):
19
+ * • Outbound: `{ type: 'presence_update', payload: { status, activity? } }`
20
+ * — server stamps `userId`, `kind`, `timestamp`, `isAgent` and
21
+ * broadcasts to other clients on the same sync groups.
22
+ * • Inbound: same frame, with `kind: 'enter' | 'update' | 'leave'`.
23
+ */
24
+ import { asyncIteratorFrom } from '../utils/asyncIterator.js';
25
+ export function createPresenceStream(config, transport = null) {
26
+ const { participantId, label, syncGroups, isAgent = false } = config;
27
+ // ── Self ─────────────────────────────────────────────────────────
28
+ const self = {
29
+ participantKind: isAgent ? 'agent' : 'human',
30
+ participantId,
31
+ label,
32
+ syncGroups: [...syncGroups],
33
+ activity: { entityType: 'Unknown', entityId: '', action: 'idle' },
34
+ lastActive: new Date().toISOString(),
35
+ };
36
+ // ── Others ───────────────────────────────────────────────────────
37
+ const othersById = new Map();
38
+ let othersSnapshot = Object.freeze([]);
39
+ const listeners = new Set();
40
+ const notifyListeners = () => {
41
+ othersSnapshot = Object.freeze(Array.from(othersById.values()));
42
+ for (const l of listeners) {
43
+ try {
44
+ l();
45
+ }
46
+ catch {
47
+ /* one bad listener doesn't break the others */
48
+ }
49
+ }
50
+ };
51
+ // ── Wire wiring ──────────────────────────────────────────────────
52
+ let attached = null;
53
+ const unsubs = [];
54
+ function attach(t) {
55
+ if (attached)
56
+ return; // idempotent
57
+ attached = t;
58
+ // Reconnect: clear roster (Hub sends fresh snapshot), re-announce
59
+ // own activity (peers don't auto-learn about us across reconnects).
60
+ unsubs.push(t.subscribe('connected', () => {
61
+ if (othersById.size > 0) {
62
+ othersById.clear();
63
+ othersSnapshot = Object.freeze([]);
64
+ notifyListeners();
65
+ }
66
+ if (self.activity.entityId)
67
+ sendUpdate(self.activity);
68
+ }));
69
+ // Inbound presence frames — translate the legacy wire vocabulary
70
+ // (userId / isAgent / timestamp) into the engine shape
71
+ // (participantId / participantKind / lastActive). When the server
72
+ // adopts the engine names this block collapses to a pass-through.
73
+ unsubs.push(t.subscribe('presence_update', (event) => {
74
+ if (event.userId === participantId)
75
+ return; // own echo
76
+ if (!event.userId)
77
+ return;
78
+ switch (event.kind) {
79
+ case 'leave':
80
+ if (othersById.delete(event.userId))
81
+ notifyListeners();
82
+ return;
83
+ case 'enter':
84
+ case 'update':
85
+ case undefined: {
86
+ const entry = {
87
+ participantKind: event.isAgent ? 'agent' : 'human',
88
+ participantId: event.userId,
89
+ syncGroups: event.syncGroups ?? [],
90
+ activity: event.activity
91
+ ? {
92
+ entityType: event.activity.entityType,
93
+ entityId: event.activity.entityId,
94
+ path: event.activity.path,
95
+ range: event.activity.range,
96
+ field: event.activity.field,
97
+ meta: event.activity.meta,
98
+ action: event.activity.action,
99
+ detail: event.activity.detail,
100
+ }
101
+ : { entityType: 'Unknown', entityId: '', action: event.status },
102
+ lastActive: event.timestamp
103
+ ? new Date(event.timestamp).toISOString()
104
+ : new Date().toISOString(),
105
+ };
106
+ othersById.set(event.userId, entry);
107
+ notifyListeners();
108
+ return;
109
+ }
110
+ }
111
+ }));
112
+ // If self was already mutated before attach, broadcast it now.
113
+ if (self.activity.entityId)
114
+ sendUpdate(self.activity);
115
+ }
116
+ if (transport)
117
+ attach(transport);
118
+ // ── Outbound ────────────────────────────────────────────────────
119
+ // Note: do NOT include `isAgent` in the payload. Server derives it
120
+ // authoritatively from the connection's identity prefix; clients
121
+ // self-declaring `isAgent` caused human sessions to broadcast as
122
+ // agents to peers (real bug we caught earlier).
123
+ function sendUpdate(activity) {
124
+ if (!attached?.isConnected())
125
+ return; // no-op until connected
126
+ attached.send({
127
+ type: 'presence_update',
128
+ payload: { status: 'online', activity },
129
+ });
130
+ }
131
+ function doUpdate(activity) {
132
+ self.activity = activity;
133
+ self.lastActive = new Date().toISOString();
134
+ sendUpdate(activity);
135
+ }
136
+ function resolveTarget(target) {
137
+ if (Array.isArray(target)) {
138
+ return { entityType: target[0], entityId: target[1], action: 'unknown' };
139
+ }
140
+ const obj = target;
141
+ return {
142
+ entityType: obj.type,
143
+ entityId: obj.id,
144
+ path: obj.path,
145
+ range: obj.range,
146
+ field: obj.field,
147
+ meta: obj.meta,
148
+ action: 'unknown',
149
+ };
150
+ }
151
+ const withVerb = (action) => (target, detail) => {
152
+ doUpdate({ ...resolveTarget(target), action, detail });
153
+ };
154
+ return {
155
+ self,
156
+ update: doUpdate,
157
+ editing: withVerb('editing'),
158
+ reading: withVerb('reading'),
159
+ viewing: withVerb('viewing'),
160
+ idle: () => {
161
+ doUpdate({ entityType: 'Unknown', entityId: '', action: 'idle' });
162
+ },
163
+ get others() {
164
+ return othersSnapshot;
165
+ },
166
+ othersIn: (syncGroup) => othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
167
+ subscribe: (listener) => {
168
+ listeners.add(listener);
169
+ return () => {
170
+ listeners.delete(listener);
171
+ };
172
+ },
173
+ [Symbol.asyncIterator]() {
174
+ return asyncIteratorFrom((onChange) => {
175
+ listeners.add(onChange);
176
+ return () => {
177
+ listeners.delete(onChange);
178
+ };
179
+ }, () => othersSnapshot);
180
+ },
181
+ attach,
182
+ dispose() {
183
+ for (const off of unsubs)
184
+ off();
185
+ unsubs.length = 0;
186
+ listeners.clear();
187
+ othersById.clear();
188
+ othersSnapshot = Object.freeze([]);
189
+ attached = null;
190
+ },
191
+ };
192
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Engine-attached snapshot factory.
3
+ *
4
+ * Captures the engine's current entity state + watermark for context-
5
+ * staleness detection. The returned Snapshot is what an LLM caller
6
+ * threads into a prompt: `stamp` flows into writes as `readAt` so the
7
+ * server rejects mutations against now-stale data; `signal` fires on
8
+ * any captured-entity delta so mid-generation invalidations abort
9
+ * the token stream rather than producing output against dead context.
10
+ *
11
+ * Reads from the engine's MobX-reactive ObjectPool, picks up the
12
+ * engine's `lastSyncId`, and subscribes to delta frames on the
13
+ * engine's transport. Same socket as entity sync — no second
14
+ * connection.
15
+ */
16
+ import type { ObjectPool } from '../ObjectPool.js';
17
+ import type { Schema } from '../schema/schema.js';
18
+ import type { SyncWebSocket } from './SyncWebSocket.js';
19
+ import type { Snapshot } from '../types/streams.js';
20
+ export interface CreateSnapshotArgs<TSchema extends Schema = Schema, K extends keyof TSchema['models'] & string = keyof TSchema['models'] & string> {
21
+ pool: ObjectPool;
22
+ /** Live transport for delta subscriptions. May be null if the engine
23
+ * hasn't connected yet — the snapshot still resolves with current
24
+ * pool state, but `signal` won't fire until reconnect. */
25
+ transport: SyncWebSocket | null;
26
+ /** Returns the engine's current `lastSyncId`. Read at snapshot time
27
+ * to stamp the watermark; not re-read after. */
28
+ getLastSyncId: () => number;
29
+ entities: {
30
+ readonly [M in K]: string | readonly string[];
31
+ };
32
+ }
33
+ export declare function createSnapshot<TSchema extends Schema, K extends keyof TSchema['models'] & string>(args: CreateSnapshotArgs<TSchema, K>): Snapshot<TSchema, K>;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Engine-attached snapshot factory.
3
+ *
4
+ * Captures the engine's current entity state + watermark for context-
5
+ * staleness detection. The returned Snapshot is what an LLM caller
6
+ * threads into a prompt: `stamp` flows into writes as `readAt` so the
7
+ * server rejects mutations against now-stale data; `signal` fires on
8
+ * any captured-entity delta so mid-generation invalidations abort
9
+ * the token stream rather than producing output against dead context.
10
+ *
11
+ * Reads from the engine's MobX-reactive ObjectPool, picks up the
12
+ * engine's `lastSyncId`, and subscribes to delta frames on the
13
+ * engine's transport. Same socket as entity sync — no second
14
+ * connection.
15
+ */
16
+ import { AbloValidationError } from '../errors.js';
17
+ import { Model, modelAsRow } from '../Model.js';
18
+ /**
19
+ * Three top-level keys that conflict with the per-model buckets if a
20
+ * customer's schema declares a model named `stamp` / `signal` /
21
+ * `onChange`. Throw at snapshot time so the collision is loud.
22
+ */
23
+ const RESERVED_SNAPSHOT_KEYS = new Set([
24
+ 'stamp',
25
+ 'signal',
26
+ 'onChange',
27
+ ]);
28
+ export function createSnapshot(args) {
29
+ const { pool, transport, getLastSyncId, entities } = args;
30
+ // ── Validate keys ────────────────────────────────────────────────
31
+ for (const key of Object.keys(entities)) {
32
+ if (RESERVED_SNAPSHOT_KEYS.has(key)) {
33
+ throw new AbloValidationError(`engine.snapshot: model key "${key}" collides with a reserved ` +
34
+ `snapshot field (stamp / signal / onChange). Rename the model ` +
35
+ 'in your schema.', { code: 'snapshot_reserved_key' });
36
+ }
37
+ }
38
+ // ── Watermark ────────────────────────────────────────────────────
39
+ const stamp = getLastSyncId();
40
+ // ── Capture data + watched set ───────────────────────────────────
41
+ const watched = new Set(); // `${type}:${id}`
42
+ const data = {};
43
+ for (const [type, idOrIds] of Object.entries(entities)) {
44
+ const ids = Array.isArray(idOrIds)
45
+ ? idOrIds
46
+ : [idOrIds];
47
+ const bucket = {};
48
+ for (const id of ids) {
49
+ const m = pool.get(id);
50
+ // Only include if the model actually has the requested type —
51
+ // pool keys models globally by id, so `pool.get(id)` could
52
+ // return a different model that happens to share the id (rare,
53
+ // but type guards keep the surface honest).
54
+ if (m && m instanceof Model && m.getModelName() === type) {
55
+ bucket[id] = modelAsRow(m);
56
+ }
57
+ watched.add(`${type}:${id}`);
58
+ }
59
+ data[type] = bucket;
60
+ }
61
+ // ── Invalidation wiring ──────────────────────────────────────────
62
+ const listeners = new Set();
63
+ const controller = new AbortController();
64
+ const fireChange = (change) => {
65
+ if (!controller.signal.aborted) {
66
+ controller.abort(new Error('snapshot invalidated — underlying entity received a delta'));
67
+ }
68
+ for (const l of listeners) {
69
+ try {
70
+ l(change);
71
+ }
72
+ catch {
73
+ /* listener errors don't break siblings */
74
+ }
75
+ }
76
+ };
77
+ let unsubDelta = null;
78
+ if (transport) {
79
+ unsubDelta = transport.subscribe('delta', (delta) => {
80
+ const key = `${delta.modelName}:${delta.modelId}`;
81
+ if (!watched.has(key))
82
+ return;
83
+ // The snapshot API treats every delta as 'semantic' severity.
84
+ // Future: distinguish metadata-only deltas (e.g., updatedAt
85
+ // bumps) from content changes — that's a separate scope.
86
+ fireChange({
87
+ model: delta.modelName,
88
+ id: delta.modelId,
89
+ severity: 'semantic',
90
+ });
91
+ });
92
+ }
93
+ // ── Build the flat result ────────────────────────────────────────
94
+ const result = {
95
+ stamp,
96
+ signal: controller.signal,
97
+ onChange: (listener) => {
98
+ listeners.add(listener);
99
+ // Caller is responsible for unsubscribing when they're done.
100
+ // The delta subscription itself stays for the snapshot's life;
101
+ // there's no public dispose because snapshots are short-lived
102
+ // (one LLM call's worth) and the transport-level subscription
103
+ // is cheap. If a long-lived consumer needs explicit teardown,
104
+ // we can add `.dispose()` in a follow-up.
105
+ return () => {
106
+ listeners.delete(listener);
107
+ // If the last listener AND the abort fired, drop the delta
108
+ // subscription too — no one's listening anymore.
109
+ if (listeners.size === 0 && controller.signal.aborted && unsubDelta) {
110
+ unsubDelta();
111
+ unsubDelta = null;
112
+ }
113
+ };
114
+ },
115
+ };
116
+ for (const [modelName, bucket] of Object.entries(data)) {
117
+ result[modelName] = bucket;
118
+ }
119
+ // Dynamic-shape boundary — `result` is built at runtime by iterating
120
+ // schema-derived buckets, so it structurally satisfies
121
+ // `Snapshot<TSchema, K>`. TS can't prove the static cast, but the
122
+ // runtime invariant holds.
123
+ return result;
124
+ }