@peers-app/peers-sdk 0.19.14 → 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.
- package/dist/contracts/__tests__/builder.test.js +24 -0
- package/dist/contracts/__tests__/contract-events.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-events.test.js +199 -0
- package/dist/contracts/__tests__/contract-observables.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-observables.test.js +290 -0
- package/dist/contracts/__tests__/contract-provider-router.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-provider-router.test.js +193 -0
- package/dist/contracts/__tests__/contract-proxy.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-proxy.test.js +424 -0
- package/dist/contracts/__tests__/contract-resolvers.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-resolvers.test.js +148 -0
- package/dist/contracts/__tests__/contract-subscription-lifecycle.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-subscription-lifecycle.test.js +353 -0
- package/dist/contracts/__tests__/contract-table-events.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-table-events.test.js +209 -0
- package/dist/contracts/__tests__/contract-transport.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-transport.test.js +163 -0
- package/dist/contracts/__tests__/persistent-registry.test.d.ts +1 -0
- package/dist/contracts/__tests__/persistent-registry.test.js +109 -0
- package/dist/contracts/__tests__/playground.test.d.ts +1 -0
- package/dist/contracts/__tests__/playground.test.js +213 -0
- package/dist/contracts/__tests__/validate.test.js +122 -0
- package/dist/contracts/builder.d.ts +9 -1
- package/dist/contracts/builder.js +13 -0
- package/dist/contracts/contract-provider-router.d.ts +72 -0
- package/dist/contracts/contract-provider-router.js +164 -0
- package/dist/contracts/contract-proxy.d.ts +333 -0
- package/dist/contracts/contract-proxy.js +663 -0
- package/dist/contracts/contract-resolvers.d.ts +66 -0
- package/dist/contracts/contract-resolvers.js +138 -0
- package/dist/contracts/contract-transport.d.ts +94 -0
- package/dist/contracts/contract-transport.js +159 -0
- package/dist/contracts/contracts.table.d.ts +1 -1
- package/dist/contracts/contracts.table.js +2 -2
- package/dist/contracts/index.d.ts +6 -1
- package/dist/contracts/index.js +25 -1
- package/dist/contracts/persistent-registry.js +3 -1
- package/dist/contracts/system/logs-contract.d.ts +24 -0
- package/dist/contracts/system/logs-contract.js +60 -0
- package/dist/contracts/types.d.ts +27 -4
- package/dist/contracts/validate.d.ts +2 -2
- package/dist/contracts/validate.js +44 -2
- package/dist/data/orm/decorators.d.ts +6 -0
- package/dist/data/orm/decorators.js +21 -0
- package/dist/data/user-trust-levels.d.ts +2 -0
- package/dist/data/user-trust-levels.js +2 -1
- package/dist/data/user-trust-levels.test.d.ts +1 -0
- package/dist/data/user-trust-levels.test.js +37 -0
- package/dist/device/binary-peer-connection-v2.d.ts +2 -0
- package/dist/device/binary-peer-connection-v2.js +12 -2
- package/dist/device/binary-peer-connection-v2.test.js +23 -0
- package/dist/device/get-trust-level-fn.d.ts +6 -0
- package/dist/device/get-trust-level-fn.js +12 -7
- package/dist/device/get-trust-level-fn.test.js +16 -2
- package/dist/logging/console-logs.table.d.ts +3 -0
- package/dist/logging/console-logs.table.js +2 -1
- package/dist/system-ids.d.ts +11 -0
- package/dist/system-ids.js +12 -1
- package/package.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* SCRATCH / PLAYGROUND — not a curated part of the suite.
|
|
5
|
+
*
|
|
6
|
+
* A place to hand-write contract *provider* (impl side) and *consumer* (caller
|
|
7
|
+
* side) tests and feel out the ergonomics. Rename, rewrite, or delete freely.
|
|
8
|
+
*
|
|
9
|
+
* Run just this file:
|
|
10
|
+
* cd peers-sdk && npx jest playground
|
|
11
|
+
* Watch it:
|
|
12
|
+
* cd peers-sdk && npx jest --watch playground
|
|
13
|
+
*/
|
|
14
|
+
const observable_1 = require("../../observable");
|
|
15
|
+
const field_type_1 = require("../../types/field-type");
|
|
16
|
+
const utils_1 = require("../../utils");
|
|
17
|
+
const contract_proxy_1 = require("../contract-proxy");
|
|
18
|
+
const contract_transport_1 = require("../contract-transport");
|
|
19
|
+
const cleanups = [];
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const cleanup of cleanups.splice(0).reverse()) {
|
|
22
|
+
cleanup();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Build a consumer wired in-process to a provider *session* (via a transport pair).
|
|
27
|
+
*
|
|
28
|
+
* The session (not the stateless `createContractProvider`) is what a consumer should face:
|
|
29
|
+
* it serves request/response dispatch *and* the subscription/streaming traffic that every
|
|
30
|
+
* consumer now opens implicitly (observable mirrors fire a `subscribe` at construction, and
|
|
31
|
+
* the session is what can push updates back on the notify channel). Returns the live session
|
|
32
|
+
* too so a test can `session.dispose()`.
|
|
33
|
+
*/
|
|
34
|
+
function inProcessConsumer(contract, resolution, opts = {}) {
|
|
35
|
+
const [consumerTransport, providerTransport] = (0, contract_transport_1.inProcessTransportPair)();
|
|
36
|
+
const session = (0, contract_proxy_1.createContractProviderSession)(resolution, providerTransport, opts);
|
|
37
|
+
const consumer = (0, contract_proxy_1.createContractConsumer)(contract, consumerTransport);
|
|
38
|
+
cleanups.push(() => {
|
|
39
|
+
consumer.dispose();
|
|
40
|
+
session.dispose();
|
|
41
|
+
});
|
|
42
|
+
return { consumer, session };
|
|
43
|
+
}
|
|
44
|
+
const CONTRACT_ID = "00mrbloudwk9svfxd42oyxqhi";
|
|
45
|
+
/** The contract *shape* a consumer sees: one table, one tool, one observable. */
|
|
46
|
+
function makeContract() {
|
|
47
|
+
return {
|
|
48
|
+
contractId: CONTRACT_ID,
|
|
49
|
+
version: 1,
|
|
50
|
+
devTag: "dev",
|
|
51
|
+
name: "Playground Contract",
|
|
52
|
+
description: "Scratch space",
|
|
53
|
+
tables: [
|
|
54
|
+
{
|
|
55
|
+
name: "Widgets",
|
|
56
|
+
description: "Widget table",
|
|
57
|
+
primaryKeyName: "widgetId",
|
|
58
|
+
fields: [{ name: "widgetId", type: field_type_1.FieldType.id, description: "PK" }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
tools: [
|
|
62
|
+
{
|
|
63
|
+
name: "make-widget",
|
|
64
|
+
usageDescription: "Makes a widget",
|
|
65
|
+
inputFields: [{ name: "color", type: field_type_1.FieldType.string, description: "Color" }],
|
|
66
|
+
outputFields: [{ name: "widgetId", type: field_type_1.FieldType.id, description: "ID" }],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
observables: [
|
|
70
|
+
{ name: "widgetCount", description: "Count", valueType: field_type_1.FieldType.number, writable: true },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** A live, in-memory implementation the *provider* dispatches to. */
|
|
75
|
+
function makeResolution() {
|
|
76
|
+
const rows = [];
|
|
77
|
+
const table = {
|
|
78
|
+
list: async (filter = {}) => rows.filter((r) => Object.entries(filter).every(([k, v]) => r[k] === v)),
|
|
79
|
+
get: async (id) => rows.find((r) => r.widgetId === id),
|
|
80
|
+
save: async (record) => {
|
|
81
|
+
rows.push(record);
|
|
82
|
+
return record;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const makeWidget = async ({ color }) => ({ widgetId: (0, utils_1.newid)(), color });
|
|
86
|
+
const widgetCount = (0, observable_1.observable)(0);
|
|
87
|
+
const resolution = {
|
|
88
|
+
contractId: CONTRACT_ID,
|
|
89
|
+
version: 1,
|
|
90
|
+
tables: { Widgets: table },
|
|
91
|
+
tools: {
|
|
92
|
+
"make-widget": { tool: { toolId: (0, utils_1.newid)(), name: "make-widget" }, toolFn: makeWidget },
|
|
93
|
+
},
|
|
94
|
+
observables: { widgetCount },
|
|
95
|
+
observableWritability: { widgetCount: true },
|
|
96
|
+
};
|
|
97
|
+
return { resolution, table, makeWidget, widgetCount, rows };
|
|
98
|
+
}
|
|
99
|
+
/** Wire a consumer straight to a provider session, in-process (over a transport pair). */
|
|
100
|
+
function wire() {
|
|
101
|
+
const contract = makeContract();
|
|
102
|
+
const { resolution, ...rest } = makeResolution();
|
|
103
|
+
const { consumer, session } = inProcessConsumer(contract, resolution);
|
|
104
|
+
return { consumer, session, contract, ...rest };
|
|
105
|
+
}
|
|
106
|
+
describe("playground", () => {
|
|
107
|
+
it("round-trips a save through consumer -> provider -> live table", async () => {
|
|
108
|
+
const { consumer } = wire();
|
|
109
|
+
const saved = await consumer.tables.Widgets.save({ widgetId: "w1", color: "red" });
|
|
110
|
+
expect(saved).toEqual({ widgetId: "w1", color: "red" });
|
|
111
|
+
const listed = await consumer.tables.Widgets.list({ color: "red" });
|
|
112
|
+
expect(listed).toEqual([{ widgetId: "w1", color: "red" }]);
|
|
113
|
+
});
|
|
114
|
+
// Start fiddling here — try a tool call, observable get/set, permission
|
|
115
|
+
// checks (sameUserContractPermissionCheck), or swap in your own contract shape.
|
|
116
|
+
it("keeps compatible contract versions isolated and reactive", async () => {
|
|
117
|
+
const logs = [];
|
|
118
|
+
let lastLogTime;
|
|
119
|
+
const inc = (0, observable_1.observable)(0);
|
|
120
|
+
const logCountObs = (0, observable_1.computed)(() => {
|
|
121
|
+
inc();
|
|
122
|
+
return logs.length;
|
|
123
|
+
});
|
|
124
|
+
const lastLogTimeObs = (0, observable_1.computed)(() => {
|
|
125
|
+
inc();
|
|
126
|
+
return lastLogTime;
|
|
127
|
+
});
|
|
128
|
+
const logTextFn = (text) => {
|
|
129
|
+
logs.push(text);
|
|
130
|
+
lastLogTime = Date.now();
|
|
131
|
+
inc(inc() + 1);
|
|
132
|
+
return logs.length - 1;
|
|
133
|
+
};
|
|
134
|
+
const logsContractV1 = {
|
|
135
|
+
contractId: CONTRACT_ID,
|
|
136
|
+
version: 1,
|
|
137
|
+
devTag: "dev",
|
|
138
|
+
name: "Playground Contract",
|
|
139
|
+
description: "Scratch space",
|
|
140
|
+
tables: [],
|
|
141
|
+
tools: [
|
|
142
|
+
{
|
|
143
|
+
name: "logText",
|
|
144
|
+
usageDescription: "logs text",
|
|
145
|
+
inputFields: [{ name: "text", type: field_type_1.FieldType.string }],
|
|
146
|
+
outputFields: [{ name: "logId", type: field_type_1.FieldType.id, description: "log id" }],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
observables: [
|
|
150
|
+
{ name: "logCount", description: "Count", valueType: field_type_1.FieldType.number, writable: false },
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
const providerV1 = {
|
|
154
|
+
contractId: CONTRACT_ID,
|
|
155
|
+
version: 1,
|
|
156
|
+
tools: {
|
|
157
|
+
logText: { tool: { toolId: "", name: "logText" }, toolFn: logTextFn },
|
|
158
|
+
},
|
|
159
|
+
observables: { logCount: logCountObs },
|
|
160
|
+
observableWritability: { logCount: false },
|
|
161
|
+
};
|
|
162
|
+
const logsContractV2 = {
|
|
163
|
+
...logsContractV1,
|
|
164
|
+
version: 2,
|
|
165
|
+
devTag: "dev",
|
|
166
|
+
observables: [
|
|
167
|
+
...logsContractV1.observables,
|
|
168
|
+
{
|
|
169
|
+
name: "lastLogTime",
|
|
170
|
+
description: "last time a log was written",
|
|
171
|
+
valueType: field_type_1.FieldType.number,
|
|
172
|
+
writable: false,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
const providerV2 = {
|
|
177
|
+
...providerV1,
|
|
178
|
+
version: 2,
|
|
179
|
+
observables: {
|
|
180
|
+
...providerV1.observables,
|
|
181
|
+
lastLogTime: lastLogTimeObs,
|
|
182
|
+
},
|
|
183
|
+
observableWritability: {
|
|
184
|
+
...providerV1.observableWritability,
|
|
185
|
+
lastLogTime: false,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const { consumer: consumerV1 } = inProcessConsumer(logsContractV1, providerV1);
|
|
189
|
+
const { consumer: consumerV2 } = inProcessConsumer(logsContractV2, providerV2);
|
|
190
|
+
await Promise.all([consumerV1.loadingPromise, consumerV2.loadingPromise]);
|
|
191
|
+
await consumerV1.tools.logText("log1");
|
|
192
|
+
expect(logs).toEqual(["log1"]);
|
|
193
|
+
let observedLogCount = consumerV1.observables.logCount();
|
|
194
|
+
expect(observedLogCount).toBe(1);
|
|
195
|
+
let resolveSecondLog;
|
|
196
|
+
const secondLogObserved = new Promise((resolve) => {
|
|
197
|
+
resolveSecondLog = resolve;
|
|
198
|
+
});
|
|
199
|
+
const observedSub = consumerV2.observables.logCount.subscribe((value) => {
|
|
200
|
+
observedLogCount = value;
|
|
201
|
+
if (value === 2) {
|
|
202
|
+
resolveSecondLog();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
cleanups.push(() => observedSub.dispose());
|
|
206
|
+
await consumerV2.tools.logText("log2");
|
|
207
|
+
await secondLogObserved;
|
|
208
|
+
expect(logs).toEqual(["log1", "log2"]);
|
|
209
|
+
expect(observedLogCount).toBe(2);
|
|
210
|
+
// TODO test tables so we can see what this looks like and how it works for both providers and consumers
|
|
211
|
+
// TODO consider renaming ContractResolution to ContractProviderInstance or something like that. ContractResolution is awkward.
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -15,6 +15,7 @@ function makeContract(overrides = {}) {
|
|
|
15
15
|
tables: [],
|
|
16
16
|
tools: [],
|
|
17
17
|
observables: [],
|
|
18
|
+
events: [],
|
|
18
19
|
...overrides,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
@@ -457,6 +458,76 @@ describe("validateProviderSatisfiesContract", () => {
|
|
|
457
458
|
expect(result.errors[0].message).toContain("writable");
|
|
458
459
|
});
|
|
459
460
|
});
|
|
461
|
+
describe("events", () => {
|
|
462
|
+
it("passes when an implementation event has compatible payload fields", () => {
|
|
463
|
+
const contract = makeContract({
|
|
464
|
+
events: [
|
|
465
|
+
{
|
|
466
|
+
name: "taskChanged",
|
|
467
|
+
description: "A task changed",
|
|
468
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
});
|
|
472
|
+
const impl = makeContract({
|
|
473
|
+
events: [
|
|
474
|
+
{
|
|
475
|
+
name: "taskChanged",
|
|
476
|
+
description: "A task changed",
|
|
477
|
+
payloadFields: [
|
|
478
|
+
{ name: "taskId", type: field_type_1.FieldType.id },
|
|
479
|
+
{ name: "source", type: field_type_1.FieldType.string, optional: true },
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
expect((0, validate_1.validateProviderSatisfiesContract)(impl, contract)).toEqual({
|
|
485
|
+
valid: true,
|
|
486
|
+
errors: [],
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
it("fails when a required event is missing", () => {
|
|
490
|
+
const contract = makeContract({
|
|
491
|
+
events: [
|
|
492
|
+
{
|
|
493
|
+
name: "taskChanged",
|
|
494
|
+
description: "A task changed",
|
|
495
|
+
payloadFields: [],
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
});
|
|
499
|
+
const result = (0, validate_1.validateProviderSatisfiesContract)(makeContract(), contract);
|
|
500
|
+
expect(result.valid).toBe(false);
|
|
501
|
+
expect(result.errors[0]).toEqual(expect.objectContaining({ kind: "event", name: "taskChanged" }));
|
|
502
|
+
});
|
|
503
|
+
it("fails when an event payload field is incompatible", () => {
|
|
504
|
+
const contract = makeContract({
|
|
505
|
+
events: [
|
|
506
|
+
{
|
|
507
|
+
name: "taskChanged",
|
|
508
|
+
description: "A task changed",
|
|
509
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
});
|
|
513
|
+
const impl = makeContract({
|
|
514
|
+
events: [
|
|
515
|
+
{
|
|
516
|
+
name: "taskChanged",
|
|
517
|
+
description: "A task changed",
|
|
518
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.number }],
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
const result = (0, validate_1.validateProviderSatisfiesContract)(impl, contract);
|
|
523
|
+
expect(result.valid).toBe(false);
|
|
524
|
+
expect(result.errors[0]).toEqual(expect.objectContaining({
|
|
525
|
+
kind: "field",
|
|
526
|
+
name: "taskChanged.payload.taskId",
|
|
527
|
+
message: expect.stringMatching(/type mismatch/),
|
|
528
|
+
}));
|
|
529
|
+
});
|
|
530
|
+
});
|
|
460
531
|
});
|
|
461
532
|
describe("validateImmutability", () => {
|
|
462
533
|
it("allows any change for dev contracts", () => {
|
|
@@ -543,6 +614,57 @@ describe("validateImmutability", () => {
|
|
|
543
614
|
expect(result.valid).toBe(false);
|
|
544
615
|
expect(result.errors[0].message).toContain("frozen");
|
|
545
616
|
});
|
|
617
|
+
it.each([
|
|
618
|
+
[
|
|
619
|
+
"addition",
|
|
620
|
+
[
|
|
621
|
+
{
|
|
622
|
+
name: "taskChanged",
|
|
623
|
+
description: "A task changed",
|
|
624
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: "taskDeleted",
|
|
628
|
+
description: "A task was deleted",
|
|
629
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
],
|
|
633
|
+
["removal", []],
|
|
634
|
+
[
|
|
635
|
+
"rename",
|
|
636
|
+
[
|
|
637
|
+
{
|
|
638
|
+
name: "taskUpdated",
|
|
639
|
+
description: "A task changed",
|
|
640
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
],
|
|
644
|
+
[
|
|
645
|
+
"payload change",
|
|
646
|
+
[
|
|
647
|
+
{
|
|
648
|
+
name: "taskChanged",
|
|
649
|
+
description: "A task changed",
|
|
650
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.number }],
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
],
|
|
654
|
+
])("rejects frozen event %s", (_change, incomingEvents) => {
|
|
655
|
+
const existing = makeContract({
|
|
656
|
+
events: [
|
|
657
|
+
{
|
|
658
|
+
name: "taskChanged",
|
|
659
|
+
description: "A task changed",
|
|
660
|
+
payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
});
|
|
664
|
+
const result = (0, validate_1.validateImmutability)(existing, makeContract({ events: incomingEvents }));
|
|
665
|
+
expect(result.valid).toBe(false);
|
|
666
|
+
expect(result.errors[0].message).toContain("frozen");
|
|
667
|
+
});
|
|
546
668
|
});
|
|
547
669
|
describe("validateAlsoImplements", () => {
|
|
548
670
|
const contractStore = new Map();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ITableDefinition } from "../data/orm/table-definitions.type";
|
|
2
2
|
import type { IToolInstance } from "../data/tools";
|
|
3
3
|
import type { IExtractableTableDef, IExtractableToolInstance } from "./extract";
|
|
4
|
-
import type { IAlsoImplementsDeclaration, IContractDefinition, IContractObservable, IPackageDefinitionResult } from "./types";
|
|
4
|
+
import type { IAlsoImplementsDeclaration, IContractDefinition, IContractEvent, IContractObservable, IPackageDefinitionResult } from "./types";
|
|
5
5
|
/**
|
|
6
6
|
* Scoped builder for a single contract within a package. Returned by
|
|
7
7
|
* `PackageBuilder.contract()`.
|
|
@@ -14,6 +14,7 @@ export declare class ContractBuilder {
|
|
|
14
14
|
private _tables;
|
|
15
15
|
private _tools;
|
|
16
16
|
private _observables;
|
|
17
|
+
private _events;
|
|
17
18
|
private _alsoImplements;
|
|
18
19
|
/** Full runtime tool instances for this contract (used by the install flow). */
|
|
19
20
|
private _toolInstances;
|
|
@@ -26,6 +27,13 @@ export declare class ContractBuilder {
|
|
|
26
27
|
get tools(): IExtractableToolInstance[];
|
|
27
28
|
set observables(obs: IContractObservable[]);
|
|
28
29
|
get observables(): IContractObservable[];
|
|
30
|
+
/**
|
|
31
|
+
* Events this contract may emit. Shape only — the live {@link Event} for each is
|
|
32
|
+
* supplied by the provider at resolution time (in `IContractResolution.events`),
|
|
33
|
+
* not here.
|
|
34
|
+
*/
|
|
35
|
+
set events(events: IContractEvent[]);
|
|
36
|
+
get events(): IContractEvent[];
|
|
29
37
|
/** Full tool instances with executable toolFn for runtime registration. */
|
|
30
38
|
set toolInstances(instances: IToolInstance[]);
|
|
31
39
|
get toolInstances(): IToolInstance[];
|
|
@@ -17,6 +17,7 @@ class ContractBuilder {
|
|
|
17
17
|
_tables = [];
|
|
18
18
|
_tools = [];
|
|
19
19
|
_observables = [];
|
|
20
|
+
_events = [];
|
|
20
21
|
_alsoImplements = [];
|
|
21
22
|
/** Full runtime tool instances for this contract (used by the install flow). */
|
|
22
23
|
_toolInstances = [];
|
|
@@ -46,6 +47,17 @@ class ContractBuilder {
|
|
|
46
47
|
get observables() {
|
|
47
48
|
return this._observables;
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Events this contract may emit. Shape only — the live {@link Event} for each is
|
|
52
|
+
* supplied by the provider at resolution time (in `IContractResolution.events`),
|
|
53
|
+
* not here.
|
|
54
|
+
*/
|
|
55
|
+
set events(events) {
|
|
56
|
+
this._events = events;
|
|
57
|
+
}
|
|
58
|
+
get events() {
|
|
59
|
+
return this._events;
|
|
60
|
+
}
|
|
49
61
|
/** Full tool instances with executable toolFn for runtime registration. */
|
|
50
62
|
set toolInstances(instances) {
|
|
51
63
|
this._toolInstances = instances;
|
|
@@ -82,6 +94,7 @@ class ContractBuilder {
|
|
|
82
94
|
tables: this._tables.map(extract_1.extractTableShape),
|
|
83
95
|
tools: this._tools.map(extract_1.extractToolShape),
|
|
84
96
|
observables: [...this._observables],
|
|
97
|
+
events: [...this._events],
|
|
85
98
|
},
|
|
86
99
|
alsoImplements: [...this._alsoImplements],
|
|
87
100
|
toolInstances: [...this._toolInstances],
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ContractNotificationSink, IContractCall, IContractCallContext, IContractProviderEndpoint } from "./contract-proxy";
|
|
2
|
+
import { type IContractTransport } from "./contract-transport";
|
|
3
|
+
/**
|
|
4
|
+
* The trusted host's authorization result for one inbound contract call.
|
|
5
|
+
*
|
|
6
|
+
* `dataContextId` is the canonical route chosen by the host. Returning the same
|
|
7
|
+
* value for an omitted personal context and an explicitly named personal context
|
|
8
|
+
* makes both calls share one cached provider endpoint.
|
|
9
|
+
*/
|
|
10
|
+
export interface IAuthorizedContractRoute {
|
|
11
|
+
/** Canonical data-context route selected by the trusted host. */
|
|
12
|
+
dataContextId?: string;
|
|
13
|
+
/** Optional caller context to pass to the resolved endpoint. */
|
|
14
|
+
context?: IContractCallContext;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Authorizes one inbound call before any provider resolver runs.
|
|
18
|
+
*
|
|
19
|
+
* Throw or reject to deny. The hook must derive authority from trusted transport
|
|
20
|
+
* state rather than caller-supplied identity fields.
|
|
21
|
+
*/
|
|
22
|
+
export type ContractCallAuthorizer = (call: IContractCall, context: IContractCallContext) => IAuthorizedContractRoute | Promise<IAuthorizedContractRoute>;
|
|
23
|
+
/** Input supplied when the router needs a provider endpoint for a new route. */
|
|
24
|
+
export interface IContractProviderEndpointRequest {
|
|
25
|
+
/** Target contract id. */
|
|
26
|
+
contractId: string;
|
|
27
|
+
/** Target contract version. */
|
|
28
|
+
version: number;
|
|
29
|
+
/** Canonical data context returned by the authorization hook. */
|
|
30
|
+
dataContextId?: string;
|
|
31
|
+
/** Authorized caller context for this route. */
|
|
32
|
+
context: IContractCallContext;
|
|
33
|
+
/** Sink for provider-to-consumer subscription notifications. */
|
|
34
|
+
notify: ContractNotificationSink;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolves an endpoint for an authorized contract/version/data-context route.
|
|
38
|
+
*
|
|
39
|
+
* The callback may adapt local live objects or return a remote/sandbox-backed
|
|
40
|
+
* endpoint. The router caches the result until disposal.
|
|
41
|
+
*/
|
|
42
|
+
export type ContractProviderEndpointResolver = (request: IContractProviderEndpointRequest) => IContractProviderEndpoint | Promise<IContractProviderEndpoint>;
|
|
43
|
+
/** Options for {@link createContractProviderRouter}. */
|
|
44
|
+
export interface IContractProviderRouterOptions {
|
|
45
|
+
/** Trusted host gate invoked before endpoint lookup or provider resolution. */
|
|
46
|
+
authorizeCall: ContractCallAuthorizer;
|
|
47
|
+
/** Resolves an endpoint after authorization succeeds. */
|
|
48
|
+
resolveEndpoint: ContractProviderEndpointResolver;
|
|
49
|
+
/** Caller identity derived from the trusted underlying connection/session. */
|
|
50
|
+
context?: IContractCallContext;
|
|
51
|
+
}
|
|
52
|
+
/** A connection-wide owner of contract request routing and endpoint lifecycle. */
|
|
53
|
+
export interface IContractProviderRouter {
|
|
54
|
+
/** Remove the request handler and dispose every resolved provider endpoint. */
|
|
55
|
+
dispose(): void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Own the sole {@link CONTRACT_REQUEST_CHANNEL} handler for a shared connection.
|
|
59
|
+
*
|
|
60
|
+
* Every ordinary call and subscribe is authorized before endpoint resolution. Caller
|
|
61
|
+
* context comes only from `opts.context`; additional transport arguments are ignored so a
|
|
62
|
+
* peer cannot override trusted connection identity. Endpoints are lazily cached by normalized
|
|
63
|
+
* contract/version/data-context route and may therefore serve many request/response calls
|
|
64
|
+
* and subscriptions. Successful subscriptions are indexed by id so unsubscribe can remain
|
|
65
|
+
* idempotent and skip reauthorization while still reaching the endpoint that owns the live
|
|
66
|
+
* source. Disposing the router removes its request handler and disposes all cached endpoints,
|
|
67
|
+
* including endpoints whose asynchronous resolution finishes during teardown.
|
|
68
|
+
*
|
|
69
|
+
* @param transport Duplex contract transport shared by all contracts on one connection.
|
|
70
|
+
* @param opts Trusted authorization, endpoint resolution, and caller context hooks.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createContractProviderRouter(transport: IContractTransport, opts: IContractProviderRouterOptions): IContractProviderRouter;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createContractProviderRouter = createContractProviderRouter;
|
|
4
|
+
const contract_transport_1 = require("./contract-transport");
|
|
5
|
+
const routeKey = (contractId, version, dataContextId) => JSON.stringify([contractId, version, dataContextId ?? null]);
|
|
6
|
+
/**
|
|
7
|
+
* Own the sole {@link CONTRACT_REQUEST_CHANNEL} handler for a shared connection.
|
|
8
|
+
*
|
|
9
|
+
* Every ordinary call and subscribe is authorized before endpoint resolution. Caller
|
|
10
|
+
* context comes only from `opts.context`; additional transport arguments are ignored so a
|
|
11
|
+
* peer cannot override trusted connection identity. Endpoints are lazily cached by normalized
|
|
12
|
+
* contract/version/data-context route and may therefore serve many request/response calls
|
|
13
|
+
* and subscriptions. Successful subscriptions are indexed by id so unsubscribe can remain
|
|
14
|
+
* idempotent and skip reauthorization while still reaching the endpoint that owns the live
|
|
15
|
+
* source. Disposing the router removes its request handler and disposes all cached endpoints,
|
|
16
|
+
* including endpoints whose asynchronous resolution finishes during teardown.
|
|
17
|
+
*
|
|
18
|
+
* @param transport Duplex contract transport shared by all contracts on one connection.
|
|
19
|
+
* @param opts Trusted authorization, endpoint resolution, and caller context hooks.
|
|
20
|
+
*/
|
|
21
|
+
function createContractProviderRouter(transport, opts) {
|
|
22
|
+
const endpointEntries = new Map();
|
|
23
|
+
const subscriptionRoutes = new Map();
|
|
24
|
+
const pendingSubscriptionIds = new Set();
|
|
25
|
+
const cancelledPendingSubscriptionIds = new Set();
|
|
26
|
+
let disposed = false;
|
|
27
|
+
const notify = (notification) => {
|
|
28
|
+
if (disposed) {
|
|
29
|
+
return Promise.reject(new Error("Contract provider router is disposed"));
|
|
30
|
+
}
|
|
31
|
+
return transport.emit(contract_transport_1.CONTRACT_NOTIFY_CHANNEL, notification);
|
|
32
|
+
};
|
|
33
|
+
const getEndpoint = (key, request) => {
|
|
34
|
+
const cached = endpointEntries.get(key);
|
|
35
|
+
if (cached) {
|
|
36
|
+
return cached.promise;
|
|
37
|
+
}
|
|
38
|
+
const entry = {};
|
|
39
|
+
entry.promise = Promise.resolve()
|
|
40
|
+
.then(() => opts.resolveEndpoint(request))
|
|
41
|
+
.then((endpoint) => {
|
|
42
|
+
entry.endpoint = endpoint;
|
|
43
|
+
if (disposed) {
|
|
44
|
+
endpoint.dispose();
|
|
45
|
+
throw new Error("Contract provider router is disposed");
|
|
46
|
+
}
|
|
47
|
+
return endpoint;
|
|
48
|
+
})
|
|
49
|
+
.catch((error) => {
|
|
50
|
+
if (endpointEntries.get(key) === entry) {
|
|
51
|
+
endpointEntries.delete(key);
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
});
|
|
55
|
+
endpointEntries.set(key, entry);
|
|
56
|
+
return entry.promise;
|
|
57
|
+
};
|
|
58
|
+
const handleUnsubscribe = async (call) => {
|
|
59
|
+
if (!call.subscriptionId) {
|
|
60
|
+
throw new Error(`${call.member} '${call.name}'.unsubscribe requires a subscriptionId`);
|
|
61
|
+
}
|
|
62
|
+
const route = subscriptionRoutes.get(call.subscriptionId);
|
|
63
|
+
if (!route) {
|
|
64
|
+
if (pendingSubscriptionIds.has(call.subscriptionId)) {
|
|
65
|
+
cancelledPendingSubscriptionIds.add(call.subscriptionId);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
subscriptionRoutes.delete(call.subscriptionId);
|
|
70
|
+
const entry = endpointEntries.get(route.endpointKey);
|
|
71
|
+
if (!entry) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const endpoint = await entry.promise;
|
|
75
|
+
await endpoint.handleCall(route.unsubscribeCall, route.context);
|
|
76
|
+
};
|
|
77
|
+
const handleAuthorizedCall = async (call) => {
|
|
78
|
+
if (disposed) {
|
|
79
|
+
throw new Error("Contract provider router is disposed");
|
|
80
|
+
}
|
|
81
|
+
if (call.operation === "unsubscribe") {
|
|
82
|
+
return handleUnsubscribe(call);
|
|
83
|
+
}
|
|
84
|
+
const isSubscribe = call.operation === "subscribe";
|
|
85
|
+
const subscriptionId = call.subscriptionId;
|
|
86
|
+
if (isSubscribe &&
|
|
87
|
+
subscriptionId &&
|
|
88
|
+
(subscriptionRoutes.has(subscriptionId) || pendingSubscriptionIds.has(subscriptionId))) {
|
|
89
|
+
throw new Error(`Duplicate contract subscriptionId '${subscriptionId}'`);
|
|
90
|
+
}
|
|
91
|
+
if (isSubscribe && subscriptionId) {
|
|
92
|
+
pendingSubscriptionIds.add(subscriptionId);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const inboundContext = opts.context ?? {};
|
|
96
|
+
const authorization = await opts.authorizeCall(call, inboundContext);
|
|
97
|
+
if (disposed) {
|
|
98
|
+
throw new Error("Contract provider router is disposed");
|
|
99
|
+
}
|
|
100
|
+
const context = authorization.context ?? inboundContext;
|
|
101
|
+
const routedCall = {
|
|
102
|
+
...call,
|
|
103
|
+
dataContextId: authorization.dataContextId,
|
|
104
|
+
};
|
|
105
|
+
const endpointKey = routeKey(call.contractId, call.version, authorization.dataContextId);
|
|
106
|
+
const endpoint = await getEndpoint(endpointKey, {
|
|
107
|
+
contractId: call.contractId,
|
|
108
|
+
version: call.version,
|
|
109
|
+
dataContextId: authorization.dataContextId,
|
|
110
|
+
context,
|
|
111
|
+
notify,
|
|
112
|
+
});
|
|
113
|
+
const result = await endpoint.handleCall(routedCall, context);
|
|
114
|
+
if (isSubscribe && subscriptionId) {
|
|
115
|
+
const subscriptionRoute = {
|
|
116
|
+
endpointKey,
|
|
117
|
+
context,
|
|
118
|
+
unsubscribeCall: {
|
|
119
|
+
...routedCall,
|
|
120
|
+
operation: "unsubscribe",
|
|
121
|
+
args: [],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
if (cancelledPendingSubscriptionIds.has(subscriptionId)) {
|
|
125
|
+
if (!disposed) {
|
|
126
|
+
await endpoint.handleCall(subscriptionRoute.unsubscribeCall, context);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (!disposed) {
|
|
130
|
+
subscriptionRoutes.set(subscriptionId, subscriptionRoute);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
if (isSubscribe && subscriptionId) {
|
|
137
|
+
pendingSubscriptionIds.delete(subscriptionId);
|
|
138
|
+
cancelledPendingSubscriptionIds.delete(subscriptionId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const offRequest = transport.on(contract_transport_1.CONTRACT_REQUEST_CHANNEL, handleAuthorizedCall);
|
|
143
|
+
return {
|
|
144
|
+
dispose() {
|
|
145
|
+
if (disposed) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
disposed = true;
|
|
149
|
+
offRequest();
|
|
150
|
+
for (const entry of endpointEntries.values()) {
|
|
151
|
+
if (entry.endpoint) {
|
|
152
|
+
entry.endpoint.dispose();
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
void entry.promise.then((endpoint) => endpoint.dispose()).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
endpointEntries.clear();
|
|
159
|
+
subscriptionRoutes.clear();
|
|
160
|
+
pendingSubscriptionIds.clear();
|
|
161
|
+
cancelledPendingSubscriptionIds.clear();
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|