@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.
- package/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- 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
|
+
}
|