@peers-app/peers-sdk 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/context/user-context.d.ts +10 -3
  2. package/dist/context/user-context.js +12 -12
  3. package/dist/contracts/__tests__/builder.test.js +158 -4
  4. package/dist/contracts/__tests__/contract-resolvers.test.js +94 -0
  5. package/dist/contracts/__tests__/host-contract-transport.test.d.ts +1 -0
  6. package/dist/contracts/__tests__/host-contract-transport.test.js +51 -0
  7. package/dist/contracts/__tests__/registry.test.js +45 -303
  8. package/dist/contracts/__tests__/system-contracts.test.d.ts +1 -0
  9. package/dist/contracts/__tests__/system-contracts.test.js +61 -0
  10. package/dist/contracts/builder.d.ts +43 -2
  11. package/dist/contracts/builder.js +79 -5
  12. package/dist/contracts/contract-proxy.d.ts +61 -21
  13. package/dist/contracts/contract-resolvers.d.ts +20 -0
  14. package/dist/contracts/contract-resolvers.js +57 -0
  15. package/dist/contracts/create-local-consumer.d.ts +12 -0
  16. package/dist/contracts/create-local-consumer.js +19 -0
  17. package/dist/contracts/host-contract-transport.d.ts +38 -0
  18. package/dist/contracts/host-contract-transport.js +107 -0
  19. package/dist/contracts/index.d.ts +8 -4
  20. package/dist/contracts/index.js +29 -1
  21. package/dist/contracts/registry.d.ts +17 -0
  22. package/dist/contracts/registry.js +36 -0
  23. package/dist/contracts/system/device-operations-contract.js +7 -0
  24. package/dist/contracts/system/index.d.ts +10 -0
  25. package/dist/contracts/system/index.js +38 -0
  26. package/dist/contracts/system/logs-contract.d.ts +18 -1
  27. package/dist/contracts/system/logs-contract.js +8 -2
  28. package/dist/contracts/system/network-contracts.d.ts +231 -0
  29. package/dist/contracts/system/network-contracts.js +192 -0
  30. package/dist/contracts/system-contracts.d.ts +47 -0
  31. package/dist/contracts/system-contracts.js +69 -0
  32. package/dist/contracts/types.d.ts +44 -0
  33. package/dist/package-loader/contract-package-loader.test.d.ts +1 -0
  34. package/dist/package-loader/contract-package-loader.test.js +88 -0
  35. package/dist/package-loader/package-loader.d.ts +8 -0
  36. package/dist/package-loader/package-loader.js +17 -1
  37. package/dist/package-loader/package-loader.test.js +17 -0
  38. package/dist/package-loader/resolve-consumed-contracts.d.ts +13 -0
  39. package/dist/package-loader/resolve-consumed-contracts.js +55 -0
  40. package/dist/package-loader/resolve-consumed-contracts.test.d.ts +1 -0
  41. package/dist/package-loader/resolve-consumed-contracts.test.js +123 -0
  42. package/dist/system-ids.d.ts +4 -0
  43. package/dist/system-ids.js +5 -1
  44. package/package.json +1 -1
@@ -23,10 +23,17 @@ export declare class UserContext {
23
23
  constructor(userId: string, dataSourceFactory: DataSourceFactory, ephemeral?: boolean | undefined);
24
24
  private init;
25
25
  /**
26
- * We want to load all packages as part of user context initialization
27
- * so any tables in packages have been registered are available at sync time
26
+ * Loads all packages for the user's personal data context and every group
27
+ * they are a part of.
28
+ *
29
+ * Callers that `await` this (e.g. device init) block until every package
30
+ * bundle has been evaluated. That is intentional when tools/contracts must
31
+ * be ready before the device is considered initialized, but it does add
32
+ * package-load latency to startup compared to fire-and-forget loading.
28
33
  */
29
- private loadAllPackages;
34
+ loadAllPackages(opts?: {
35
+ force?: boolean;
36
+ }): Promise<void>;
30
37
  private loadGroupContexts;
31
38
  getDataContext(groupId: string): DataContext;
32
39
  /** Wait for any pending `currentlyActiveGroupId` pvar write to be flushed to the DB. */
@@ -44,22 +44,22 @@ class UserContext {
44
44
  await this.loadUserContextObservablesFromDB();
45
45
  }
46
46
  await this.loadGroupContexts();
47
- await this.loadAllPackages();
48
47
  return this;
49
48
  }
50
49
  /**
51
- * We want to load all packages as part of user context initialization
52
- * so any tables in packages have been registered are available at sync time
50
+ * Loads all packages for the user's personal data context and every group
51
+ * they are a part of.
52
+ *
53
+ * Callers that `await` this (e.g. device init) block until every package
54
+ * bundle has been evaluated. That is intentional when tools/contracts must
55
+ * be ready before the device is considered initialized, but it does add
56
+ * package-load latency to startup compared to fire-and-forget loading.
53
57
  */
54
- loadAllPackages() {
55
- this.userDataContext.packageLoader
56
- .loadAllPackages()
57
- .catch((err) => console.error("[UserContext] loadAllPackages failed for userDataContext:", err));
58
- for (const groupContext of this.groupDataContexts.values()) {
59
- groupContext.packageLoader
60
- .loadAllPackages()
61
- .catch((err) => console.error("[UserContext] loadAllPackages failed for groupContext:", err));
62
- }
58
+ async loadAllPackages(opts) {
59
+ await Promise.all([
60
+ this.userDataContext.packageLoader.loadAllPackages(opts),
61
+ ...[...this.groupDataContexts.values()].map((ctx) => ctx.packageLoader.loadAllPackages(opts)),
62
+ ]);
63
63
  }
64
64
  async loadGroupContexts() {
65
65
  const groupsTable = (0, data_1.Groups)(this.userDataContext);
@@ -97,10 +97,12 @@ describe("definePackage", () => {
97
97
  expect(result.contracts[1].contractId).toBe(NOTES_ID);
98
98
  });
99
99
  it("records consumed contracts", () => {
100
+ let tasksHandle;
101
+ let authHandle;
100
102
  const result = (0, builder_1.definePackage)((pkg) => {
101
103
  pkg.packageId = TEST_PKG_ID;
102
- pkg.consumes(TASKS_ID, 1);
103
- pkg.consumes(AUTH_ID, 2);
104
+ tasksHandle = pkg.consumes(TASKS_ID, 1);
105
+ authHandle = pkg.consumes(AUTH_ID, 2);
104
106
  const projects = pkg.contract(PROJECTS_ID, 1, "Projects");
105
107
  projects.tables = [
106
108
  {
@@ -114,8 +116,160 @@ describe("definePackage", () => {
114
116
  ];
115
117
  });
116
118
  expect(result.consumes).toHaveLength(2);
117
- expect(result.consumes[0]).toEqual({ contractId: TASKS_ID, version: 1 });
118
- expect(result.consumes[1]).toEqual({ contractId: AUTH_ID, version: 2 });
119
+ expect(result.consumes[0].contractId).toBe(TASKS_ID);
120
+ expect(result.consumes[0].version).toBe(1);
121
+ expect(result.consumes[0].handle).toBe(tasksHandle);
122
+ expect(result.consumes[1].contractId).toBe(AUTH_ID);
123
+ expect(result.consumes[1].version).toBe(2);
124
+ expect(result.consumes[1].handle).toBe(authHandle);
125
+ });
126
+ it("preserves consume options and returns a deferred handle", () => {
127
+ const preferredProviderId = (0, utils_1.newid)();
128
+ const result = (0, builder_1.definePackage)((pkg) => {
129
+ pkg.packageId = TEST_PKG_ID;
130
+ pkg.consumes(TASKS_ID, 1, {
131
+ providerPackageIds: [preferredProviderId],
132
+ fallbackToDefaultProvider: true,
133
+ });
134
+ pkg.consumes(AUTH_ID, 2, {
135
+ optional: true,
136
+ providerPackageIds: [preferredProviderId],
137
+ fallbackToDefaultProvider: false,
138
+ });
139
+ });
140
+ const [required, optional] = result.consumes;
141
+ expect(required).toMatchObject({
142
+ contractId: TASKS_ID,
143
+ version: 1,
144
+ providerPackageIds: [preferredProviderId],
145
+ fallbackToDefaultProvider: true,
146
+ });
147
+ expect(required.optional).toBeUndefined();
148
+ expect(required.handle).toBeDefined();
149
+ expect(typeof required.handle?.consumer.then).toBe("function");
150
+ // Handle itself must not be thenable (naive await on the return value must not wait).
151
+ expect(required.handle.then).toBeUndefined();
152
+ expect(optional).toMatchObject({
153
+ contractId: AUTH_ID,
154
+ version: 2,
155
+ optional: true,
156
+ providerPackageIds: [preferredProviderId],
157
+ fallbackToDefaultProvider: false,
158
+ });
159
+ expect(optional.handle).toBeDefined();
160
+ });
161
+ it("accepts a type argument that narrows the resolved consumer", () => {
162
+ (0, builder_1.definePackage)((pkg) => {
163
+ pkg.packageId = TEST_PKG_ID;
164
+ const required = pkg.consumes(TASKS_ID, 1);
165
+ const optional = pkg.consumes(AUTH_ID, 1, { optional: true });
166
+ // Compile-time checks: required → TypedConsumer; optional → TypedConsumer | undefined.
167
+ void (async () => {
168
+ const requiredConsumer = await required.consumer;
169
+ const optionalConsumer = await optional.consumer;
170
+ void requiredConsumer;
171
+ void optionalConsumer;
172
+ // Named table methods (not only dataChanged) must be present on ITableProxy.
173
+ const _list = requiredConsumer.tables.ConsoleLogs.list;
174
+ const _deleteOldLogs = requiredConsumer.tables.ConsoleLogs.deleteOldLogs;
175
+ void _list;
176
+ void _deleteOldLogs;
177
+ // Typed consumers may omit empty bags — these must not exist on TypedConsumer.
178
+ // @ts-expect-error no tools bag on slim typed consumer
179
+ void requiredConsumer.tools;
180
+ // @ts-expect-error no events bag on slim typed consumer
181
+ void requiredConsumer.events;
182
+ // @ts-expect-error no observables bag on slim typed consumer
183
+ void requiredConsumer.observables;
184
+ // @ts-expect-error optional consume must allow undefined
185
+ const tooNarrow = await optional.consumer;
186
+ void tooNarrow;
187
+ })();
188
+ });
189
+ });
190
+ it("ILogsConsumer exposes ConsoleLogs as ITableProxy without empty bags", () => {
191
+ (0, builder_1.definePackage)((pkg) => {
192
+ pkg.packageId = TEST_PKG_ID;
193
+ const handle = pkg.consumes(TASKS_ID, 1, { optional: true });
194
+ void (async () => {
195
+ const logs = await handle.consumer;
196
+ if (!logs)
197
+ return;
198
+ const _list = logs.tables.ConsoleLogs.list;
199
+ const _deleteOldLogs = logs.tables.ConsoleLogs.deleteOldLogs;
200
+ void _list;
201
+ void _deleteOldLogs;
202
+ // @ts-expect-error ILogsConsumer has no tools bag
203
+ void logs.tools;
204
+ // @ts-expect-error ILogsConsumer has no events bag
205
+ void logs.events;
206
+ // @ts-expect-error ILogsConsumer has no observables bag
207
+ void logs.observables;
208
+ })();
209
+ });
210
+ });
211
+ it("settles required handles with a consumer and optional handles with undefined", async () => {
212
+ const { settleConsumedContractHandle, rejectConsumedContractHandle } = await Promise.resolve().then(() => require("../builder"));
213
+ const result = (0, builder_1.definePackage)((pkg) => {
214
+ pkg.packageId = TEST_PKG_ID;
215
+ pkg.consumes(TASKS_ID, 1);
216
+ pkg.consumes(AUTH_ID, 1, { optional: true });
217
+ });
218
+ const requiredHandle = result.consumes[0].handle;
219
+ const optionalHandle = result.consumes[1].handle;
220
+ expect(requiredHandle).toBeDefined();
221
+ expect(optionalHandle).toBeDefined();
222
+ if (!requiredHandle || !optionalHandle) {
223
+ throw new Error("expected consumes handles");
224
+ }
225
+ const fakeConsumer = {
226
+ contractId: TASKS_ID,
227
+ version: 1,
228
+ tables: {},
229
+ tools: {},
230
+ observables: {},
231
+ events: {},
232
+ loadingPromise: Promise.resolve(),
233
+ dispose: () => { },
234
+ };
235
+ settleConsumedContractHandle(requiredHandle, fakeConsumer);
236
+ settleConsumedContractHandle(optionalHandle, undefined);
237
+ await expect(requiredHandle.consumer).resolves.toBe(fakeConsumer);
238
+ await expect(optionalHandle.consumer).resolves.toBeUndefined();
239
+ expect(() => settleConsumedContractHandle(requiredHandle, fakeConsumer)).toThrow("already been settled");
240
+ expect(() => rejectConsumedContractHandle(optionalHandle, new Error("late"))).toThrow("already been settled");
241
+ });
242
+ it("rejects a required consume handle once", async () => {
243
+ const { rejectConsumedContractHandle } = await Promise.resolve().then(() => require("../builder"));
244
+ const result = (0, builder_1.definePackage)((pkg) => {
245
+ pkg.packageId = TEST_PKG_ID;
246
+ pkg.consumes(TASKS_ID, 1);
247
+ });
248
+ const requiredHandle = result.consumes[0].handle;
249
+ expect(requiredHandle).toBeDefined();
250
+ if (!requiredHandle) {
251
+ throw new Error("expected consumes handle");
252
+ }
253
+ const err = new Error("No provider for Tasks");
254
+ rejectConsumedContractHandle(requiredHandle, err);
255
+ await expect(requiredHandle.consumer).rejects.toThrow("No provider for Tasks");
256
+ expect(() => rejectConsumedContractHandle(requiredHandle, err)).toThrow("already been settled");
257
+ });
258
+ it("throws when consumes providerPackageIds contains an invalid peer ID", () => {
259
+ expect(() => {
260
+ (0, builder_1.definePackage)((pkg) => {
261
+ pkg.packageId = TEST_PKG_ID;
262
+ pkg.consumes(TASKS_ID, 1, { providerPackageIds: ["not-valid"] });
263
+ });
264
+ }).toThrow("providerPackageIds");
265
+ });
266
+ it("throws when consumes version is not a positive integer", () => {
267
+ expect(() => {
268
+ (0, builder_1.definePackage)((pkg) => {
269
+ pkg.packageId = TEST_PKG_ID;
270
+ pkg.consumes(TASKS_ID, 0);
271
+ });
272
+ }).toThrow("positive integer");
119
273
  });
120
274
  it("records alsoImplements declarations", () => {
121
275
  const result = (0, builder_1.definePackage)((pkg) => {
@@ -146,3 +146,97 @@ describe("Self-trusted contract caller authorization", () => {
146
146
  router.dispose();
147
147
  });
148
148
  });
149
+ describe("local user-context contract routing", () => {
150
+ function setupLocalRouter() {
151
+ const userId = (0, utils_1.newid)();
152
+ const groupId = (0, utils_1.newid)();
153
+ const personalContext = { dataContextId: userId };
154
+ const groupContext = { dataContextId: groupId };
155
+ const getDataContext = jest.fn((dataContextId) => {
156
+ if (dataContextId === userId)
157
+ return personalContext;
158
+ if (dataContextId === groupId)
159
+ return groupContext;
160
+ throw new Error("unexpected data context");
161
+ });
162
+ const userContext = {
163
+ userId,
164
+ userDataContext: personalContext,
165
+ groupDataContexts: new Map([[groupId, groupContext]]),
166
+ groupIds: jest.fn(() => [groupId]),
167
+ getDataContext,
168
+ };
169
+ const contractId = (0, utils_1.newid)();
170
+ (0, index_1.registerContractResolver)(contractId, 1, ({ dataContext }) => ({
171
+ contractId,
172
+ version: 1,
173
+ tools: {
174
+ inspect: {
175
+ tool: {
176
+ toolId: (0, utils_1.newid)(),
177
+ name: "inspect",
178
+ usageDescription: "Return the authorized route.",
179
+ inputSchema: { type: "simple", fields: [] },
180
+ outputSchema: { type: "simple", fields: [] },
181
+ code: "",
182
+ packageId: (0, utils_1.newid)(),
183
+ },
184
+ toolFn: () => dataContext.dataContextId,
185
+ },
186
+ },
187
+ }));
188
+ const [consumerTransport, providerTransport] = (0, index_1.inProcessTransportPair)();
189
+ return {
190
+ consumerTransport,
191
+ contractId,
192
+ getDataContext,
193
+ groupId,
194
+ providerTransport,
195
+ userContext,
196
+ userId,
197
+ };
198
+ }
199
+ const makeToolCall = (contractId, dataContextId) => ({
200
+ contractId,
201
+ version: 1,
202
+ member: "tool",
203
+ name: "inspect",
204
+ operation: "run",
205
+ args: [],
206
+ dataContextId,
207
+ });
208
+ it("registers synchronously and waits for a lazily loaded user context", async () => {
209
+ const setup = setupLocalRouter();
210
+ let resolveUserContext;
211
+ const userContextPromise = new Promise((resolve) => {
212
+ resolveUserContext = resolve;
213
+ });
214
+ const router = (0, index_1.createLocalUserContextContractProviderRouter)(setup.providerTransport, {
215
+ getUserContext: () => userContextPromise,
216
+ });
217
+ const result = setup.consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeToolCall(setup.contractId));
218
+ resolveUserContext(setup.userContext);
219
+ await expect(result).resolves.toBe(setup.userId);
220
+ router.dispose();
221
+ });
222
+ it("routes known groups and rejects unknown contexts without creating them", async () => {
223
+ const setup = setupLocalRouter();
224
+ const router = (0, index_1.createLocalUserContextContractProviderRouter)(setup.providerTransport, {
225
+ getUserContext: async () => setup.userContext,
226
+ });
227
+ await expect(setup.consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeToolCall(setup.contractId, setup.groupId))).resolves.toBe(setup.groupId);
228
+ const unknownId = (0, utils_1.newid)();
229
+ await expect(setup.consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeToolCall(setup.contractId, unknownId))).rejects.toThrow("not available locally");
230
+ expect(setup.getDataContext).not.toHaveBeenCalledWith(unknownId);
231
+ router.dispose();
232
+ });
233
+ it("enforces an optional contract allowlist before resolving a provider", async () => {
234
+ const setup = setupLocalRouter();
235
+ const router = (0, index_1.createLocalUserContextContractProviderRouter)(setup.providerTransport, {
236
+ getUserContext: async () => setup.userContext,
237
+ allowedContractIds: new Set([(0, utils_1.newid)()]),
238
+ });
239
+ await expect(setup.consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeToolCall(setup.contractId))).rejects.toThrow("contract");
240
+ router.dispose();
241
+ });
242
+ });
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("../index");
4
+ describe("host contract transport", () => {
5
+ const disposers = [];
6
+ afterEach(() => {
7
+ while (disposers.length > 0) {
8
+ disposers.pop()?.();
9
+ }
10
+ });
11
+ it("publishes registration lifecycle and clears the active transport", () => {
12
+ const [transport] = (0, index_1.inProcessTransportPair)();
13
+ const values = [];
14
+ const unsubscribe = (0, index_1.subscribeHostContractTransport)((value) => values.push(value));
15
+ const dispose = (0, index_1.registerHostContractTransport)(transport);
16
+ disposers.push(dispose);
17
+ expect((0, index_1.getHostContractTransport)()).toBe(transport);
18
+ dispose();
19
+ unsubscribe();
20
+ expect((0, index_1.getHostContractTransport)()).toBeUndefined();
21
+ expect(values).toEqual([undefined, transport, undefined]);
22
+ });
23
+ it("does not let a stale registration disposer clear its replacement", () => {
24
+ const [first] = (0, index_1.inProcessTransportPair)();
25
+ const [second] = (0, index_1.inProcessTransportPair)();
26
+ const disposeFirst = (0, index_1.registerHostContractTransport)(first);
27
+ const disposeSecond = (0, index_1.registerHostContractTransport)(second);
28
+ disposers.push(disposeFirst, disposeSecond);
29
+ disposeFirst();
30
+ expect((0, index_1.getHostContractTransport)()).toBe(second);
31
+ });
32
+ it("waits for a transport registered after startup", async () => {
33
+ const pending = (0, index_1.waitForHostContractTransport)();
34
+ const [transport] = (0, index_1.inProcessTransportPair)();
35
+ const dispose = (0, index_1.registerHostContractTransport)(transport);
36
+ disposers.push(dispose);
37
+ await expect(pending).resolves.toBe(transport);
38
+ });
39
+ it("rejects when no transport arrives before timeoutMs", async () => {
40
+ jest.useFakeTimers();
41
+ try {
42
+ const pending = (0, index_1.waitForHostContractTransport)({ timeoutMs: 25 });
43
+ const expectation = expect(pending).rejects.toThrow("Timed out waiting for host contract transport after 25ms");
44
+ await jest.advanceTimersByTimeAsync(25);
45
+ await expectation;
46
+ }
47
+ finally {
48
+ jest.useRealTimers();
49
+ }
50
+ });
51
+ });