@peers-app/peers-sdk 0.19.13 → 0.20.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 (59) hide show
  1. package/dist/contracts/__tests__/builder.test.js +24 -0
  2. package/dist/contracts/__tests__/contract-events.test.d.ts +1 -0
  3. package/dist/contracts/__tests__/contract-events.test.js +199 -0
  4. package/dist/contracts/__tests__/contract-observables.test.d.ts +1 -0
  5. package/dist/contracts/__tests__/contract-observables.test.js +290 -0
  6. package/dist/contracts/__tests__/contract-provider-router.test.d.ts +1 -0
  7. package/dist/contracts/__tests__/contract-provider-router.test.js +193 -0
  8. package/dist/contracts/__tests__/contract-proxy.test.d.ts +1 -0
  9. package/dist/contracts/__tests__/contract-proxy.test.js +424 -0
  10. package/dist/contracts/__tests__/contract-resolvers.test.d.ts +1 -0
  11. package/dist/contracts/__tests__/contract-resolvers.test.js +148 -0
  12. package/dist/contracts/__tests__/contract-subscription-lifecycle.test.d.ts +1 -0
  13. package/dist/contracts/__tests__/contract-subscription-lifecycle.test.js +353 -0
  14. package/dist/contracts/__tests__/contract-table-events.test.d.ts +1 -0
  15. package/dist/contracts/__tests__/contract-table-events.test.js +209 -0
  16. package/dist/contracts/__tests__/contract-transport.test.d.ts +1 -0
  17. package/dist/contracts/__tests__/contract-transport.test.js +163 -0
  18. package/dist/contracts/__tests__/persistent-registry.test.d.ts +1 -0
  19. package/dist/contracts/__tests__/persistent-registry.test.js +109 -0
  20. package/dist/contracts/__tests__/playground.test.d.ts +1 -0
  21. package/dist/contracts/__tests__/playground.test.js +213 -0
  22. package/dist/contracts/__tests__/validate.test.js +122 -0
  23. package/dist/contracts/builder.d.ts +9 -1
  24. package/dist/contracts/builder.js +13 -0
  25. package/dist/contracts/contract-provider-router.d.ts +72 -0
  26. package/dist/contracts/contract-provider-router.js +164 -0
  27. package/dist/contracts/contract-proxy.d.ts +333 -0
  28. package/dist/contracts/contract-proxy.js +663 -0
  29. package/dist/contracts/contract-resolvers.d.ts +66 -0
  30. package/dist/contracts/contract-resolvers.js +138 -0
  31. package/dist/contracts/contract-transport.d.ts +94 -0
  32. package/dist/contracts/contract-transport.js +159 -0
  33. package/dist/contracts/contracts.table.d.ts +1 -1
  34. package/dist/contracts/contracts.table.js +2 -2
  35. package/dist/contracts/index.d.ts +6 -1
  36. package/dist/contracts/index.js +25 -1
  37. package/dist/contracts/persistent-registry.js +3 -1
  38. package/dist/contracts/system/logs-contract.d.ts +24 -0
  39. package/dist/contracts/system/logs-contract.js +60 -0
  40. package/dist/contracts/types.d.ts +27 -4
  41. package/dist/contracts/validate.d.ts +2 -2
  42. package/dist/contracts/validate.js +44 -2
  43. package/dist/data/orm/decorators.d.ts +6 -0
  44. package/dist/data/orm/decorators.js +21 -0
  45. package/dist/data/user-trust-levels.d.ts +2 -0
  46. package/dist/data/user-trust-levels.js +2 -1
  47. package/dist/data/user-trust-levels.test.d.ts +1 -0
  48. package/dist/data/user-trust-levels.test.js +37 -0
  49. package/dist/device/binary-peer-connection-v2.d.ts +2 -0
  50. package/dist/device/binary-peer-connection-v2.js +12 -2
  51. package/dist/device/binary-peer-connection-v2.test.js +23 -0
  52. package/dist/device/get-trust-level-fn.d.ts +6 -0
  53. package/dist/device/get-trust-level-fn.js +12 -7
  54. package/dist/device/get-trust-level-fn.test.js +16 -2
  55. package/dist/logging/console-logs.table.d.ts +3 -0
  56. package/dist/logging/console-logs.table.js +2 -1
  57. package/dist/system-ids.d.ts +11 -0
  58. package/dist/system-ids.js +12 -1
  59. package/package.json +1 -1
@@ -0,0 +1,663 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sameUserContractPermissionCheck = exports.CONTRACT_CALL_RPC = void 0;
4
+ exports.createContractConsumer = createContractConsumer;
5
+ exports.createContractProvider = createContractProvider;
6
+ exports.createContractProviderEndpoint = createContractProviderEndpoint;
7
+ exports.createContractProviderSession = createContractProviderSession;
8
+ const decorators_1 = require("../data/orm/decorators");
9
+ const observable_1 = require("../observable");
10
+ const utils_1 = require("../utils");
11
+ const contract_transport_1 = require("./contract-transport");
12
+ const STANDARD_REMOTE_TABLE_METHODS = new Set([
13
+ "get",
14
+ "list",
15
+ "count",
16
+ "findOne",
17
+ "save",
18
+ "insert",
19
+ "update",
20
+ "delete",
21
+ ]);
22
+ const FORBIDDEN_REMOTE_TABLE_PROPERTIES = new Set([
23
+ "__proto__",
24
+ "constructor",
25
+ "prototype",
26
+ "dataChanged",
27
+ "dataChangedEmitter",
28
+ "subscribe",
29
+ "unsubscribe",
30
+ "then",
31
+ "toString",
32
+ "valueOf",
33
+ ]);
34
+ /**
35
+ * The RPC event name used to proxy a single {@link IContractCall} over a
36
+ * {@link Connection} (`connection.emit(CONTRACT_CALL_RPC, call)` on the consumer
37
+ * side; `connection.exposeRPC(CONTRACT_CALL_RPC, handler)` on the provider side).
38
+ *
39
+ * Alias of {@link CONTRACT_REQUEST_CHANNEL} — the fixed request/response channel of an
40
+ * {@link IContractTransport}. Kept for existing provider registrations that reference it.
41
+ */
42
+ exports.CONTRACT_CALL_RPC = contract_transport_1.CONTRACT_REQUEST_CHANNEL;
43
+ /**
44
+ * Build the **consumer** side of a contract proxy from a contract definition and a
45
+ * transport. Member names come from the contract shape; table members are backed by a
46
+ * `Proxy` that forwards accessed method names. The provider accepts only standard data
47
+ * operations and custom methods marked by `ProxyClientTableMethodCalls`.
48
+ *
49
+ * Every access is turned into an {@link IContractCall} sent on the transport's fixed
50
+ * {@link CONTRACT_REQUEST_CHANNEL}; the resolved ack is the provider's return value.
51
+ *
52
+ * The consumer also subscribes to provider pushes: `events[name].subscribe(handler)` and
53
+ * `tables[name].dataChanged.subscribe(handler)` both send a `"subscribe"` request and
54
+ * demultiplex provider pushes arriving on {@link CONTRACT_NOTIFY_CHANNEL} by their
55
+ * `subscriptionId` (shared `subscribeVia` path). Observables use the same push channel but
56
+ * are *not* gated: each `observables[name]` is a live {@link Observable} mirror that subscribes
57
+ * implicitly at creation, loads an initial snapshot (awaitable via
58
+ * {@link IContractConsumer.loadingPromise}), and streams every subsequent change; local writes
59
+ * send a fire-and-forget `set`. Call {@link IContractConsumer.dispose} to remove the notify
60
+ * listener and unsubscribe every stream. Read-only observable writes are rejected on both
61
+ * consumer and provider boundaries. Disposal is idempotent and prevents new remote operations;
62
+ * cached observable reads remain available afterward.
63
+ *
64
+ * @param contract The contract definition (shape) being consumed.
65
+ * @param transport Bidirectional channel to the provider; requests go out on
66
+ * {@link CONTRACT_REQUEST_CHANNEL}, notifications arrive on {@link CONTRACT_NOTIFY_CHANNEL}.
67
+ * @param opts.dataContextId Optional data context to scope every call to.
68
+ */
69
+ function createContractConsumer(contract, transport, opts = {}) {
70
+ const { contractId, version } = contract;
71
+ const { dataContextId } = opts;
72
+ let disposed = false;
73
+ const emitRequest = (call) => transport.emit(contract_transport_1.CONTRACT_REQUEST_CHANNEL, call);
74
+ const request = (call) => disposed
75
+ ? Promise.reject(new Error(`Contract consumer ${contractId} v${version} is disposed`))
76
+ : emitRequest(call);
77
+ // Notify demux: one listener on the shared notify channel routes each push to the
78
+ // handler that owns its subscriptionId. Notifications for other consumers' ids
79
+ // (a shared transport is multiplexed) simply find no handler and are dropped.
80
+ const notifyHandlers = new Map();
81
+ const subscriptions = new Set();
82
+ const observableWriteSubscriptions = [];
83
+ const offNotify = transport.on(contract_transport_1.CONTRACT_NOTIFY_CHANNEL, (n) => {
84
+ notifyHandlers.get(n.subscriptionId)?.(n.value);
85
+ });
86
+ /**
87
+ * Shared subscribe path for every subscription-gated surface (generic events and table
88
+ * `dataChanged`). Registers `handler` under a fresh `subscriptionId` before asking the
89
+ * provider to start sending (so no push can arrive before its handler exists), fires the
90
+ * subscribe request fire-and-forget (like the local {@link Event} API), and returns an
91
+ * idempotent {@link ISubscriptionResult}. If unsubscribe or disposal happens before the
92
+ * subscribe ack, the wire unsubscribe is queued behind that ack; a failed subscribe sends
93
+ * no redundant unsubscribe. `buildCall` maps the `(subscriptionId, operation)` pair to the
94
+ * exact wire call for that surface.
95
+ */
96
+ const subscribeVia = (buildCall) => (handler) => {
97
+ if (disposed) {
98
+ throw new Error(`Contract consumer ${contractId} v${version} is disposed`);
99
+ }
100
+ const subscriptionId = (0, utils_1.newid)();
101
+ let active = true;
102
+ let unsubscribeSent = false;
103
+ notifyHandlers.set(subscriptionId, handler);
104
+ const subscribeAck = emitRequest(buildCall(subscriptionId, "subscribe"))
105
+ .then(() => true)
106
+ .catch((err) => {
107
+ active = false;
108
+ notifyHandlers.delete(subscriptionId);
109
+ subscriptions.delete(subscription);
110
+ console.error("Contract subscribe failed", err);
111
+ return false;
112
+ });
113
+ const sendUnsubscribe = async () => {
114
+ if (unsubscribeSent) {
115
+ return;
116
+ }
117
+ unsubscribeSent = true;
118
+ try {
119
+ await emitRequest(buildCall(subscriptionId, "unsubscribe"));
120
+ }
121
+ catch (err) {
122
+ console.error("Contract unsubscribe failed", err);
123
+ }
124
+ };
125
+ const subscription = {
126
+ unsubscribe: () => {
127
+ if (!active) {
128
+ return false;
129
+ }
130
+ active = false;
131
+ notifyHandlers.delete(subscriptionId);
132
+ subscriptions.delete(subscription);
133
+ void subscribeAck.then((subscribed) => {
134
+ if (subscribed) {
135
+ return sendUnsubscribe();
136
+ }
137
+ });
138
+ return true;
139
+ },
140
+ };
141
+ subscriptions.add(subscription);
142
+ return subscription;
143
+ };
144
+ const tables = {};
145
+ for (const table of contract.tables) {
146
+ const name = table.name;
147
+ // `dataChanged` is a subscribable that rides the notify channel (created lazily and
148
+ // cached); every other accessed name forwards as a remote method call.
149
+ let dataChanged;
150
+ tables[name] = new Proxy({}, {
151
+ get(_target, operation) {
152
+ if (typeof operation !== "string") {
153
+ return undefined;
154
+ }
155
+ if (operation === "dataChanged") {
156
+ dataChanged ??= {
157
+ subscribe: subscribeVia((subscriptionId, op) => ({
158
+ contractId,
159
+ version,
160
+ member: "table",
161
+ name,
162
+ operation: op,
163
+ event: "dataChanged",
164
+ subscriptionId,
165
+ args: [],
166
+ dataContextId,
167
+ })),
168
+ };
169
+ return dataChanged;
170
+ }
171
+ return (...args) => request({
172
+ contractId,
173
+ version,
174
+ member: "table",
175
+ name,
176
+ operation,
177
+ args,
178
+ dataContextId,
179
+ });
180
+ },
181
+ });
182
+ }
183
+ const tools = {};
184
+ for (const tool of contract.tools) {
185
+ const name = tool.name;
186
+ tools[name] = (...args) => request({
187
+ contractId,
188
+ version,
189
+ member: "tool",
190
+ name,
191
+ operation: "run",
192
+ args,
193
+ dataContextId,
194
+ });
195
+ }
196
+ const events = {};
197
+ for (const evt of contract.events ?? []) {
198
+ const name = evt.name;
199
+ events[name] = {
200
+ subscribe: subscribeVia((subscriptionId, operation) => ({
201
+ contractId,
202
+ version,
203
+ member: "event",
204
+ name,
205
+ operation,
206
+ subscriptionId,
207
+ args: [],
208
+ dataContextId,
209
+ })),
210
+ };
211
+ }
212
+ // Observables are backed by real, callable Observable mirrors (see below).
213
+ const observables = {};
214
+ const observableLoads = [];
215
+ const dispose = () => {
216
+ if (disposed) {
217
+ return;
218
+ }
219
+ disposed = true;
220
+ for (const sub of [...subscriptions]) {
221
+ sub.unsubscribe();
222
+ }
223
+ for (const sub of observableWriteSubscriptions) {
224
+ sub.dispose();
225
+ }
226
+ offNotify();
227
+ notifyHandlers.clear();
228
+ };
229
+ const consumer = {
230
+ contractId,
231
+ version,
232
+ tables,
233
+ tools,
234
+ observables,
235
+ events,
236
+ loadingPromise: Promise.resolve(),
237
+ dispose,
238
+ };
239
+ // Build each observable as a locally-mirrored Observable kept current by provider pushes
240
+ // (decision 4: observables are always streamed so reads are synchronous). Writes are
241
+ // fire-and-forget and chained onto the proxy-level loadingPromise for awaitable durability
242
+ // — the same shape as a persistent var (see data/persistent-vars.ts).
243
+ for (const obs of contract.observables) {
244
+ const name = obs.name;
245
+ const mirror = (0, observable_1.observable)();
246
+ // Guards against echo loops: while applying a provider push we must not re-`set` it, and
247
+ // once a push has updated the mirror a later (possibly stale) initial `get` is ignored.
248
+ let applyingRemote = false;
249
+ let pushed = false;
250
+ const applyRemote = (value) => {
251
+ if (disposed) {
252
+ return;
253
+ }
254
+ pushed = true;
255
+ applyingRemote = true;
256
+ try {
257
+ mirror(value);
258
+ }
259
+ finally {
260
+ applyingRemote = false;
261
+ }
262
+ };
263
+ // Implicit, always-on subscription (not gated). `subscribeVia` registers the handler
264
+ // before asking the provider to start streaming, so no push can arrive unhandled.
265
+ subscribeVia((subscriptionId, operation) => ({
266
+ contractId,
267
+ version,
268
+ member: "observable",
269
+ name,
270
+ operation,
271
+ subscriptionId,
272
+ args: [],
273
+ dataContextId,
274
+ }))(applyRemote);
275
+ // Expose a guarded callable facade: cached reads remain available after disposal, while
276
+ // writes are rejected because they would otherwise mutate a dead mirror without reaching
277
+ // the provider. Provider pushes bypass this facade through applyRemote above.
278
+ const exposedMirror = ((...args) => {
279
+ if (args.length === 0) {
280
+ return mirror();
281
+ }
282
+ if (disposed) {
283
+ throw new Error(`Contract consumer ${contractId} v${version} is disposed`);
284
+ }
285
+ if (!obs.writable) {
286
+ throw new Error(`Observable '${name}' is read-only`);
287
+ }
288
+ return mirror(args[0]);
289
+ });
290
+ exposedMirror.subscribe = mirror.subscribe;
291
+ exposedMirror.notifySubscribers = mirror.notifySubscribers;
292
+ exposedMirror.subscriberCount = mirror.subscriberCount;
293
+ // Initial snapshot so the mirror has a value to read synchronously. A push that arrived
294
+ // first wins — the snapshot may already be stale by the time it resolves.
295
+ observableLoads.push(request({
296
+ contractId,
297
+ version,
298
+ member: "observable",
299
+ name,
300
+ operation: "get",
301
+ args: [],
302
+ dataContextId,
303
+ })
304
+ .then((value) => {
305
+ if (!disposed && !pushed) {
306
+ applyRemote(value);
307
+ }
308
+ })
309
+ .catch((err) => console.error("Contract observable initial get failed", err)));
310
+ // Local writes → fire-and-forget `set`, chained onto loadingPromise for durability.
311
+ observableWriteSubscriptions.push(mirror.subscribe((value) => {
312
+ if (applyingRemote || disposed) {
313
+ return;
314
+ }
315
+ consumer.loadingPromise = consumer.loadingPromise.then(() => request({
316
+ contractId,
317
+ version,
318
+ member: "observable",
319
+ name,
320
+ operation: "set",
321
+ args: [value],
322
+ dataContextId,
323
+ })
324
+ .then(() => undefined)
325
+ .catch((err) => console.error("Contract observable set failed", err)));
326
+ }));
327
+ observables[name] = exposedMirror;
328
+ }
329
+ consumer.loadingPromise = Promise.all(observableLoads).then(() => undefined);
330
+ return consumer;
331
+ }
332
+ /**
333
+ * Legacy gate that allows calls only when caller and provider user ids match.
334
+ *
335
+ * @deprecated Production connection routing uses the signed-identity, provider-personal
336
+ * Self-trust policy in `createUserContextContractProviderRouter`. Keep this helper only
337
+ * for compatibility and isolated in-process providers that intentionally need the old gate.
338
+ */
339
+ const sameUserContractPermissionCheck = (_call, context) => {
340
+ return Boolean(context.localUserId) && context.callerUserId === context.localUserId;
341
+ };
342
+ exports.sameUserContractPermissionCheck = sameUserContractPermissionCheck;
343
+ /**
344
+ * Build the **provider** side of a contract proxy: an async handler that permission-checks
345
+ * an {@link IContractCall} and dispatches it to the live table method / tool fn / observable
346
+ * accessor in the given resolution. Table dispatch is constrained to standard serializable
347
+ * data operations plus custom methods explicitly marked by `ProxyClientTableMethodCalls`;
348
+ * internal helpers, event properties, document/cursor helpers, and prototype properties
349
+ * are not remotely callable.
350
+ *
351
+ * @param resolution The live contract implementation to dispatch to.
352
+ * @param opts.permissionCheck Optional gate run before every dispatch.
353
+ */
354
+ function createContractProvider(resolution, opts = {}) {
355
+ const { permissionCheck } = opts;
356
+ return async (call, context = {}) => {
357
+ if (call.contractId !== resolution.contractId || call.version !== resolution.version) {
358
+ throw new Error(`Contract mismatch: resolution provides ${resolution.contractId} v${resolution.version} ` +
359
+ `but call targets ${call.contractId} v${call.version}`);
360
+ }
361
+ if (permissionCheck) {
362
+ const allowed = await permissionCheck(call, context);
363
+ if (!allowed) {
364
+ throw new Error(`Permission denied: ${call.member} '${call.name}'.${call.operation} on contract ${call.contractId}`);
365
+ }
366
+ }
367
+ switch (call.member) {
368
+ case "table": {
369
+ const table = resolution.tables?.[call.name];
370
+ if (!table) {
371
+ throw new Error(`Contract ${call.contractId} does not provide table '${call.name}'`);
372
+ }
373
+ if (FORBIDDEN_REMOTE_TABLE_PROPERTIES.has(call.operation)) {
374
+ throw new Error(`Table method '${call.name}.${call.operation}' is not remotely callable`);
375
+ }
376
+ const method = table[call.operation];
377
+ // Unwrap client-proxy methods so we call the real implementation, not a
378
+ // method that would re-forward the call (see ProxyClientTableMethodCalls).
379
+ const impl = typeof method === "function" ? (method.__original ?? method) : undefined;
380
+ if (typeof impl !== "function") {
381
+ throw new Error(`Table '${call.name}' has no method '${call.operation}'`);
382
+ }
383
+ if (!STANDARD_REMOTE_TABLE_METHODS.has(call.operation) &&
384
+ !(0, decorators_1.isContractRemoteTableMethod)(method)) {
385
+ throw new Error(`Table method '${call.name}.${call.operation}' is not remotely callable`);
386
+ }
387
+ return impl.apply(table, call.args);
388
+ }
389
+ case "tool": {
390
+ if (call.operation !== "run") {
391
+ throw new Error(`Unsupported tool operation '${call.operation}' on '${call.name}'`);
392
+ }
393
+ const tool = resolution.tools?.[call.name];
394
+ if (!tool) {
395
+ throw new Error(`Contract ${call.contractId} does not provide tool '${call.name}'`);
396
+ }
397
+ return tool.toolFn(...call.args);
398
+ }
399
+ case "observable": {
400
+ const obs = resolution.observables?.[call.name];
401
+ if (!obs) {
402
+ throw new Error(`Contract ${call.contractId} does not provide observable '${call.name}'`);
403
+ }
404
+ if (call.operation === "get") {
405
+ return obs();
406
+ }
407
+ if (call.operation === "set") {
408
+ if (resolution.observableWritability?.[call.name] !== true) {
409
+ throw new Error(`Observable '${call.name}' is read-only`);
410
+ }
411
+ return obs(call.args[0]);
412
+ }
413
+ throw new Error(`Unsupported observable operation '${call.operation}' on '${call.name}'`);
414
+ }
415
+ default:
416
+ throw new Error(`Unsupported contract member type '${call.member}'`);
417
+ }
418
+ };
419
+ }
420
+ /**
421
+ * Build a stateful **provider endpoint**: it serves the same request/response dispatch as
422
+ * {@link createContractProvider} for table/tool/observable calls, and additionally serves
423
+ * subscriptions on any streamable member — generic `event` calls, table events (a `table`
424
+ * member with `"subscribe"` / `"unsubscribe"`, resolving `dataChanged` by default), and
425
+ * observable value streams (an `observable` member with `"subscribe"` / `"unsubscribe"`;
426
+ * `get`/`set` still flow to the stateless dispatch).
427
+ *
428
+ * The live source — an {@link Event} (`resolution.events[name]` or a table's `dataChanged`)
429
+ * or an {@link Observable} (`resolution.observables[name]`) — is only subscribed to while at
430
+ * least one consumer holds a subscription (refcounted by a resolved key), and each payload is
431
+ * fanned out through the supplied {@link ContractNotificationSink} as an
432
+ * {@link IContractNotification}. The endpoint owns no request listener, which lets a
433
+ * connection-wide router multiplex many endpoints through one request channel and later
434
+ * substitute sandbox-backed endpoint implementations.
435
+ *
436
+ * @param resolution The live contract implementation to dispatch to and stream from.
437
+ * @param opts Notification sink plus optional permission gate and default context.
438
+ */
439
+ function createContractProviderEndpoint(resolution, opts) {
440
+ const { notify, permissionCheck, context: defaultContext } = opts;
441
+ const dispatch = createContractProvider(resolution, { permissionCheck });
442
+ // One live subscription per resolved subscription key, refcounted by the set of
443
+ // consumer subscription ids currently attached to it, plus a reverse index for
444
+ // unsubscribe. Keys namespace generic events (`event:<name>`), table events
445
+ // (`table:<name>:<event>`), and observable streams (`observable:<name>`) so the same
446
+ // machinery serves all three.
447
+ const byKey = new Map();
448
+ const subIdToKey = new Map();
449
+ const pendingSubIds = new Set();
450
+ let disposed = false;
451
+ const notifySink = (subscriptionId, value, debugName) => notify({
452
+ subscriptionId,
453
+ value,
454
+ contractId: resolution.contractId,
455
+ name: debugName,
456
+ });
457
+ /**
458
+ * Resolve a subscription call to a stable refcount `key` plus an `attach` function that
459
+ * hooks a payload handler onto the live source and returns a detach callback. This
460
+ * normalizes the three streamable sources behind one interface:
461
+ * - generic `event` → `resolution.events[name]` (an {@link Event}; `subscribe` returns
462
+ * `{ unsubscribe }`), key `event:<name>`.
463
+ * - observable value stream → `resolution.observables[name]` (an {@link Observable};
464
+ * `subscribe` returns `{ dispose }`), key `observable:<name>`.
465
+ * - table event → `resolution.tables[name][event ?? "dataChanged"]` (an {@link Event};
466
+ * a `Table` exposes `dataChanged` as a plain `Event`), key `table:<name>:<event>`.
467
+ */
468
+ const resolveSubscription = (call) => {
469
+ if (call.member === "event") {
470
+ const liveEvent = resolution.events?.[call.name];
471
+ if (!liveEvent) {
472
+ throw new Error(`Contract ${resolution.contractId} does not provide event '${call.name}'`);
473
+ }
474
+ return {
475
+ key: `event:${call.name}`,
476
+ attach: (handler) => {
477
+ const sub = liveEvent.subscribe(handler);
478
+ return () => sub.unsubscribe();
479
+ },
480
+ };
481
+ }
482
+ if (call.member === "observable") {
483
+ const obs = resolution.observables?.[call.name];
484
+ if (!obs) {
485
+ throw new Error(`Contract ${resolution.contractId} does not provide observable '${call.name}'`);
486
+ }
487
+ return {
488
+ key: `observable:${call.name}`,
489
+ attach: (handler) => {
490
+ const sub = obs.subscribe(handler);
491
+ return () => sub.dispose();
492
+ },
493
+ };
494
+ }
495
+ const table = resolution.tables?.[call.name];
496
+ if (!table) {
497
+ throw new Error(`Contract ${resolution.contractId} does not provide table '${call.name}'`);
498
+ }
499
+ const evtName = call.event ?? "dataChanged";
500
+ const liveEvent = table[evtName];
501
+ if (!liveEvent || typeof liveEvent.subscribe !== "function") {
502
+ throw new Error(`Table '${call.name}' does not provide event '${evtName}'`);
503
+ }
504
+ return {
505
+ key: `table:${call.name}:${evtName}`,
506
+ attach: (handler) => {
507
+ const sub = liveEvent.subscribe(handler);
508
+ return () => sub.unsubscribe();
509
+ },
510
+ };
511
+ };
512
+ const subscribe = (key, attach, subscriptionId) => {
513
+ if (subIdToKey.has(subscriptionId)) {
514
+ throw new Error(`Duplicate contract subscriptionId '${subscriptionId}'`);
515
+ }
516
+ let entry = byKey.get(key);
517
+ if (!entry) {
518
+ // First subscriber for this key: attach the single live subscription.
519
+ const subIds = new Set();
520
+ const detach = attach((payload) => {
521
+ // Start each notification independently. A slow or rejected destination must not
522
+ // block sibling subscribers, and every rejection is handled because Observable
523
+ // callbacks do not await returned promises.
524
+ for (const sid of [...subIds]) {
525
+ void notifySink(sid, payload, key).catch((err) => {
526
+ console.error(`Contract notify failed for subscription '${sid}'`, err);
527
+ });
528
+ }
529
+ });
530
+ entry = { liveUnsub: detach, subIds };
531
+ byKey.set(key, entry);
532
+ }
533
+ subIdToKey.set(subscriptionId, key);
534
+ entry.subIds.add(subscriptionId);
535
+ };
536
+ const unsubscribe = (subscriptionId) => {
537
+ const key = subIdToKey.get(subscriptionId);
538
+ if (!key) {
539
+ return;
540
+ }
541
+ subIdToKey.delete(subscriptionId);
542
+ const entry = byKey.get(key);
543
+ if (!entry) {
544
+ return;
545
+ }
546
+ entry.subIds.delete(subscriptionId);
547
+ if (entry.subIds.size === 0) {
548
+ // Last subscriber left: detach the live subscription (nothing more is pushed).
549
+ entry.liveUnsub();
550
+ byKey.delete(key);
551
+ }
552
+ };
553
+ const validateSubscriptionCall = (call) => {
554
+ if (call.member !== "event" && call.member !== "table" && call.member !== "observable") {
555
+ throw new Error(`Contract member type '${call.member}' does not support subscriptions`);
556
+ }
557
+ if (call.operation !== "subscribe" && call.operation !== "unsubscribe") {
558
+ throw new Error(`Unsupported subscription operation '${call.operation}' on '${call.name}'`);
559
+ }
560
+ if (call.member === "table" && call.event !== undefined && call.event !== "dataChanged") {
561
+ throw new Error(`Unsupported table event '${call.event}' on '${call.name}'`);
562
+ }
563
+ if (call.member !== "table" && call.event !== undefined) {
564
+ throw new Error(`Contract ${call.member} '${call.name}'.${call.operation} cannot specify a table event`);
565
+ }
566
+ };
567
+ const handleSubscriptionCall = async (call, context) => {
568
+ if (call.contractId !== resolution.contractId || call.version !== resolution.version) {
569
+ throw new Error(`Contract mismatch: resolution provides ${resolution.contractId} v${resolution.version} ` +
570
+ `but call targets ${call.contractId} v${call.version}`);
571
+ }
572
+ validateSubscriptionCall(call);
573
+ if (!call.subscriptionId) {
574
+ throw new Error(`${call.member} '${call.name}'.${call.operation} requires a subscriptionId`);
575
+ }
576
+ if (call.operation === "subscribe") {
577
+ if (subIdToKey.has(call.subscriptionId) || pendingSubIds.has(call.subscriptionId)) {
578
+ throw new Error(`Duplicate contract subscriptionId '${call.subscriptionId}'`);
579
+ }
580
+ pendingSubIds.add(call.subscriptionId);
581
+ try {
582
+ if (permissionCheck) {
583
+ const allowed = await permissionCheck(call, context);
584
+ if (!allowed) {
585
+ throw new Error(`Permission denied: ${call.member} '${call.name}'.subscribe on contract ${call.contractId}`);
586
+ }
587
+ }
588
+ if (disposed) {
589
+ throw new Error(`Contract provider endpoint ${resolution.contractId} v${resolution.version} is disposed`);
590
+ }
591
+ const { key, attach } = resolveSubscription(call);
592
+ subscribe(key, attach, call.subscriptionId);
593
+ }
594
+ finally {
595
+ pendingSubIds.delete(call.subscriptionId);
596
+ }
597
+ return;
598
+ }
599
+ if (call.operation === "unsubscribe") {
600
+ unsubscribe(call.subscriptionId);
601
+ return;
602
+ }
603
+ throw new Error(`Unsupported subscription operation '${call.operation}' on '${call.name}'`);
604
+ };
605
+ return {
606
+ handleCall(call, suppliedContext) {
607
+ if (disposed) {
608
+ return Promise.reject(new Error(`Contract provider endpoint ${resolution.contractId} v${resolution.version} is disposed`));
609
+ }
610
+ const context = suppliedContext ?? defaultContext ?? {};
611
+ // The reserved subscribe/unsubscribe operations are subscription traffic on every
612
+ // streamable member (generic events, table events, and observable value streams);
613
+ // everything else (table methods, tool runs, observable get/set) is stateless dispatch.
614
+ const isSubscription = call.member === "event" ||
615
+ call.operation === "subscribe" ||
616
+ call.operation === "unsubscribe";
617
+ return isSubscription ? handleSubscriptionCall(call, context) : dispatch(call, context);
618
+ },
619
+ dispose() {
620
+ if (disposed) {
621
+ return;
622
+ }
623
+ disposed = true;
624
+ for (const entry of byKey.values()) {
625
+ entry.liveUnsub();
626
+ }
627
+ byKey.clear();
628
+ subIdToKey.clear();
629
+ pendingSubIds.clear();
630
+ },
631
+ };
632
+ }
633
+ /**
634
+ * Build the backward-compatible transport-bound provider session. The session owns the
635
+ * transport's request listener while its underlying {@link IContractProviderEndpoint} owns
636
+ * request dispatch and live subscription state.
637
+ *
638
+ * Use {@link createContractProviderEndpoint} behind a connection-wide router when multiple
639
+ * contracts or data contexts share one transport.
640
+ *
641
+ * @param resolution The live contract implementation to dispatch to and stream from.
642
+ * @param transport Requests arrive on {@link CONTRACT_REQUEST_CHANNEL}; notifications are
643
+ * emitted on {@link CONTRACT_NOTIFY_CHANNEL}.
644
+ * @param opts Optional permission gate and default caller context.
645
+ */
646
+ function createContractProviderSession(resolution, transport, opts = {}) {
647
+ const endpoint = createContractProviderEndpoint(resolution, {
648
+ ...opts,
649
+ notify: (notification) => transport.emit(contract_transport_1.CONTRACT_NOTIFY_CHANNEL, notification),
650
+ });
651
+ const offRequest = transport.on(contract_transport_1.CONTRACT_REQUEST_CHANNEL, (call, context) => endpoint.handleCall(call, context));
652
+ let disposed = false;
653
+ return {
654
+ dispose() {
655
+ if (disposed) {
656
+ return;
657
+ }
658
+ disposed = true;
659
+ offRequest();
660
+ endpoint.dispose();
661
+ },
662
+ };
663
+ }