@peers-app/peers-sdk 0.26.6 → 0.27.1

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 (189) hide show
  1. package/README.md +3 -1
  2. package/bin/peers-package.js +13 -0
  3. package/define/package.json +4 -0
  4. package/dist/contracts/__tests__/action-dispatch.test.js +56 -76
  5. package/dist/contracts/__tests__/also-implements.test.d.ts +1 -0
  6. package/dist/contracts/__tests__/also-implements.test.js +110 -0
  7. package/dist/contracts/__tests__/builder.test.js +45 -0
  8. package/dist/contracts/__tests__/contract-resolvers.test.js +134 -4
  9. package/dist/contracts/__tests__/frozen-contracts.test.d.ts +1 -0
  10. package/dist/contracts/__tests__/frozen-contracts.test.js +191 -0
  11. package/dist/contracts/__tests__/generic-tool-dispatch.test.js +13 -56
  12. package/dist/contracts/__tests__/host-contract-client.test.d.ts +1 -0
  13. package/dist/contracts/__tests__/host-contract-client.test.js +793 -0
  14. package/dist/contracts/__tests__/host-contract-transport.test.js +15 -0
  15. package/dist/contracts/__tests__/registry.test.js +68 -0
  16. package/dist/contracts/__tests__/target-dispatch.d.ts +11 -0
  17. package/dist/contracts/__tests__/target-dispatch.js +24 -0
  18. package/dist/contracts/__tests__/tool-catalog-service.test.js +37 -1
  19. package/dist/contracts/__tests__/tool-catalog.test.js +12 -3
  20. package/dist/contracts/__tests__/validate.test.js +177 -7
  21. package/dist/contracts/__tests__/workflow-context.test.js +0 -11
  22. package/dist/contracts/builder.d.ts +16 -2
  23. package/dist/contracts/builder.js +44 -3
  24. package/dist/contracts/contract-definition-schema.d.ts +251 -0
  25. package/dist/contracts/contract-definition-schema.js +48 -0
  26. package/dist/contracts/contract-provider-router.d.ts +2 -0
  27. package/dist/contracts/contract-provider-router.js +5 -2
  28. package/dist/contracts/contract-proxy.d.ts +52 -18
  29. package/dist/contracts/contract-proxy.js +144 -203
  30. package/dist/contracts/contract-resolvers.d.ts +6 -4
  31. package/dist/contracts/contract-resolvers.js +32 -9
  32. package/dist/contracts/frozen-contracts.d.ts +59 -0
  33. package/dist/contracts/frozen-contracts.js +86 -0
  34. package/dist/contracts/host-contract-client.d.ts +134 -0
  35. package/dist/contracts/host-contract-client.js +396 -0
  36. package/dist/contracts/host-contract-transport.d.ts +2 -0
  37. package/dist/contracts/host-contract-transport.js +8 -0
  38. package/dist/contracts/index.d.ts +6 -4
  39. package/dist/contracts/index.js +11 -5
  40. package/dist/contracts/registry.d.ts +31 -1
  41. package/dist/contracts/registry.js +100 -6
  42. package/dist/contracts/system/contract-catalog-contract.d.ts +61 -6
  43. package/dist/contracts/system/contract-catalog-contract.js +17 -5
  44. package/dist/contracts/tool-catalog-service.js +26 -6
  45. package/dist/contracts/tool-catalog.d.ts +1 -1
  46. package/dist/contracts/tool-catalog.js +4 -1
  47. package/dist/contracts/types.d.ts +32 -2
  48. package/dist/contracts/validate.d.ts +16 -1
  49. package/dist/contracts/validate.js +139 -25
  50. package/dist/data/change-tracking.d.ts +17 -5
  51. package/dist/data/change-tracking.js +22 -6
  52. package/dist/data/change-tracking.test.d.ts +1 -0
  53. package/dist/data/change-tracking.test.js +62 -0
  54. package/dist/data/create-group.js +1 -1
  55. package/dist/data/group-members.d.ts +14 -0
  56. package/dist/data/group-members.js +36 -0
  57. package/dist/data/group-members.test.js +49 -0
  58. package/dist/data/orm/sql.data-source.d.ts +9 -0
  59. package/dist/data/orm/sql.data-source.js +47 -2
  60. package/dist/data/orm/sql.data-source.test.js +142 -0
  61. package/dist/data/orm/table-container.js +9 -9
  62. package/dist/data/orm/table-container.test.js +44 -0
  63. package/dist/data/orm/types.d.ts +41 -1
  64. package/dist/data/orm/types.js +159 -28
  65. package/dist/data/orm/types.test.js +129 -0
  66. package/dist/data/peer-types.d.ts +8 -62
  67. package/dist/data/persistent-vars.d.ts +4 -1
  68. package/dist/data/persistent-vars.js +7 -2
  69. package/dist/data/tools.d.ts +29 -221
  70. package/dist/define/also-implements.test.d.ts +1 -0
  71. package/dist/define/also-implements.test.js +123 -0
  72. package/dist/define/context.d.ts +77 -0
  73. package/dist/define/context.js +2 -0
  74. package/dist/define/contract-ref.d.ts +177 -0
  75. package/dist/define/contract-ref.js +67 -0
  76. package/dist/define/declare-contract.d.ts +167 -0
  77. package/dist/define/declare-contract.js +220 -0
  78. package/dist/define/declare-contract.test.d.ts +1 -0
  79. package/dist/define/declare-contract.test.js +573 -0
  80. package/dist/define/define-package.d.ts +203 -0
  81. package/dist/define/define-package.js +289 -0
  82. package/dist/define/define.test.d.ts +1 -0
  83. package/dist/define/define.test.js +1010 -0
  84. package/dist/define/empty-fields.d.ts +15 -0
  85. package/dist/define/empty-fields.js +134 -0
  86. package/dist/define/empty-fields.test.d.ts +1 -0
  87. package/dist/define/empty-fields.test.js +55 -0
  88. package/dist/define/events-observables.test.d.ts +1 -0
  89. package/dist/define/events-observables.test.js +1013 -0
  90. package/dist/define/events.d.ts +81 -0
  91. package/dist/define/events.js +81 -0
  92. package/dist/define/generated-field.d.ts +20 -0
  93. package/dist/define/generated-field.js +25 -0
  94. package/dist/define/index.d.ts +28 -0
  95. package/dist/define/index.js +62 -0
  96. package/dist/define/lazy-members.d.ts +8 -0
  97. package/dist/define/lazy-members.js +38 -0
  98. package/dist/define/manifest.d.ts +63 -0
  99. package/dist/define/manifest.js +653 -0
  100. package/dist/define/observables.d.ts +146 -0
  101. package/dist/define/observables.js +187 -0
  102. package/dist/define/serve-members.d.ts +21 -0
  103. package/dist/define/serve-members.js +152 -0
  104. package/dist/define/serve-members.test.d.ts +1 -0
  105. package/dist/define/serve-members.test.js +630 -0
  106. package/dist/define/serve.d.ts +22 -0
  107. package/dist/define/serve.js +58 -0
  108. package/dist/define/system-contracts.d.ts +41 -0
  109. package/dist/define/system-contracts.js +21 -0
  110. package/dist/define/table-handle.d.ts +13 -0
  111. package/dist/define/table-handle.js +36 -0
  112. package/dist/define/table.d.ts +128 -0
  113. package/dist/define/table.js +122 -0
  114. package/dist/define/tool.d.ts +129 -0
  115. package/dist/define/tool.js +91 -0
  116. package/dist/define/vars.d.ts +82 -0
  117. package/dist/define/vars.js +256 -0
  118. package/dist/define/vars.test.d.ts +1 -0
  119. package/dist/define/vars.test.js +39 -0
  120. package/dist/define/wire-value.d.ts +12 -0
  121. package/dist/define/wire-value.js +76 -0
  122. package/dist/define/worker-state.d.ts +15 -0
  123. package/dist/define/worker-state.js +45 -0
  124. package/dist/device/tx-encoding.test.js +10 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.js +5 -1
  127. package/dist/invites/group-approval.js +4 -2
  128. package/dist/invites/group-approval.test.js +2 -0
  129. package/dist/observable.d.ts +42 -2
  130. package/dist/observable.js +25 -7
  131. package/dist/package-build/build-package.d.ts +60 -0
  132. package/dist/package-build/build-package.js +312 -0
  133. package/dist/package-build/build-package.test.d.ts +1 -0
  134. package/dist/package-build/build-package.test.js +204 -0
  135. package/dist/package-build/cli.d.ts +7 -0
  136. package/dist/package-build/cli.js +218 -0
  137. package/dist/package-build/contract-add.d.ts +35 -0
  138. package/dist/package-build/contract-add.js +97 -0
  139. package/dist/package-build/contract-add.test.d.ts +1 -0
  140. package/dist/package-build/contract-add.test.js +467 -0
  141. package/dist/package-build/contract-source.d.ts +59 -0
  142. package/dist/package-build/contract-source.js +471 -0
  143. package/dist/package-build/freeze-contracts.d.ts +56 -0
  144. package/dist/package-build/freeze-contracts.js +198 -0
  145. package/dist/package-build/freeze-contracts.test.d.ts +1 -0
  146. package/dist/package-build/freeze-contracts.test.js +355 -0
  147. package/dist/package-installer/package-creator.d.ts +4 -0
  148. package/dist/package-installer/package-creator.js +57 -16
  149. package/dist/package-installer/package-creator.test.js +86 -0
  150. package/dist/package-loader/contract-members.d.ts +53 -0
  151. package/dist/package-loader/contract-members.js +31 -0
  152. package/dist/package-loader/contract-package-loader.js +5 -1
  153. package/dist/package-loader/contract-package-loader.test.js +11 -0
  154. package/dist/package-loader/host-authority-loading.test.js +9 -5
  155. package/dist/package-loader/index.d.ts +1 -0
  156. package/dist/package-loader/index.js +1 -0
  157. package/dist/package-loader/isolated-artifact.d.ts +80 -17
  158. package/dist/package-loader/isolated-artifact.js +257 -51
  159. package/dist/package-loader/isolated-artifact.test.js +294 -21
  160. package/dist/package-loader/isolated-runtime.d.ts +22 -7
  161. package/dist/package-loader/isolated-table-gateway.js +30 -11
  162. package/dist/package-loader/isolated-table-gateway.test.js +243 -0
  163. package/dist/package-loader/package-loader.d.ts +8 -1
  164. package/dist/package-loader/package-loader.js +39 -20
  165. package/dist/package-loader/package-loader.test.js +47 -0
  166. package/dist/peer-id.d.ts +25 -0
  167. package/dist/peer-id.js +99 -0
  168. package/dist/serial-json.compat.test.d.ts +1 -0
  169. package/dist/serial-json.compat.test.js +369 -0
  170. package/dist/serial-json.d.ts +41 -5
  171. package/dist/serial-json.js +216 -157
  172. package/dist/serial-json.oracle.d.ts +21 -0
  173. package/dist/serial-json.oracle.js +222 -0
  174. package/dist/serial-json.test.js +315 -0
  175. package/dist/testing/index.d.ts +4 -0
  176. package/dist/testing/index.js +8 -0
  177. package/dist/testing/mock-host-contract.d.ts +123 -0
  178. package/dist/testing/mock-host-contract.js +434 -0
  179. package/dist/testing/mock-host-contract.test.d.ts +1 -0
  180. package/dist/testing/mock-host-contract.test.js +782 -0
  181. package/dist/types/field-type.d.ts +35 -18
  182. package/dist/types/field-type.js +4 -1
  183. package/dist/types/zod-types.d.ts +9 -0
  184. package/dist/types/zod-types.js +8 -2
  185. package/dist/utils.d.ts +22 -25
  186. package/dist/utils.js +45 -92
  187. package/dist/utils.test.js +36 -0
  188. package/package.json +9 -2
  189. package/testing/package.json +4 -0
@@ -36,6 +36,21 @@ describe("host contract transport", () => {
36
36
  disposers.push(dispose);
37
37
  await expect(pending).resolves.toBe(transport);
38
38
  });
39
+ it("cancels waits and clears their deadline", async () => {
40
+ jest.useFakeTimers();
41
+ try {
42
+ const controller = new AbortController();
43
+ const pending = (0, index_1.waitForHostContractTransport)({ signal: controller.signal, timeoutMs: 25 });
44
+ const rejected = expect(pending).rejects.toThrow("cancelled");
45
+ controller.abort(new Error("cancelled"));
46
+ await rejected;
47
+ expect(jest.getTimerCount()).toBe(0);
48
+ await expect((0, index_1.waitForHostContractTransport)({ signal: controller.signal })).rejects.toThrow("cancelled");
49
+ }
50
+ finally {
51
+ jest.useRealTimers();
52
+ }
53
+ });
39
54
  it("rejects when no transport arrives before timeoutMs", async () => {
40
55
  jest.useFakeTimers();
41
56
  try {
@@ -168,3 +168,71 @@ describe("ContractRegistry.replaceBatch", () => {
168
168
  expect(registry.getRegisteredProviderIds(rejectedContractId, 1)).toEqual([]);
169
169
  });
170
170
  });
171
+ describe("ContractRegistry exposes", () => {
172
+ const hubId = (0, utils_1.newid)();
173
+ const newsId = (0, utils_1.newid)();
174
+ const provider = (0, utils_1.newid)();
175
+ const other = (0, utils_1.newid)();
176
+ it("keeps each provider's exposures, outside the contract's definition", () => {
177
+ const registry = new registry_1.ContractRegistry();
178
+ const result = registry.registerBatch(provider, [
179
+ { definition: makeDefinition(newsId, 1, "News") },
180
+ {
181
+ definition: makeDefinition(hubId, 2, "Hub"),
182
+ exposes: [{ contractId: newsId, version: 1 }],
183
+ },
184
+ ]);
185
+ expect(result.valid).toBe(true);
186
+ registry.registerBatch(other, [{ definition: makeDefinition(hubId, 2, "Hub") }]);
187
+ expect(registry.getExposedContracts(hubId, 2, provider)).toEqual([
188
+ { contractId: newsId, version: 1 },
189
+ ]);
190
+ expect(registry.getExposedContracts(hubId, 2, other)).toEqual([]);
191
+ expect(registry.getExposedContracts(newsId, 1, provider)).toEqual([]);
192
+ expect(registry.getExposedContracts(hubId, 2, (0, utils_1.newid)())).toEqual([]);
193
+ expect(registry.getDefinition(hubId, 2)).not.toHaveProperty("exposes");
194
+ expect(registry.getProviderDefinition(newsId, 1, provider)?.name).toBe("News");
195
+ expect(registry.getProviderDefinition(newsId, 1, other)).toBeUndefined();
196
+ // A caller can't change what the registry holds through the returned list.
197
+ registry.getExposedContracts(hubId, 2, provider).pop();
198
+ expect(registry.getExposedContracts(hubId, 2, provider)).toHaveLength(1);
199
+ });
200
+ it("carries exposures to an alsoImplements alias, and drops them on replacement", () => {
201
+ const registry = new registry_1.ContractRegistry();
202
+ registry.register(other, makeDefinition(hubId, 1, "Hub v1"));
203
+ registry.registerBatch(provider, [
204
+ { definition: makeDefinition(newsId, 1, "News") },
205
+ {
206
+ definition: makeDefinition(hubId, 2, "Hub"),
207
+ alsoImplements: [{ contractId: hubId, version: 1 }],
208
+ exposes: [{ contractId: newsId, version: 1 }],
209
+ },
210
+ ]);
211
+ expect(registry.getExposedContracts(hubId, 1, provider)).toEqual([
212
+ { contractId: newsId, version: 1 },
213
+ ]);
214
+ registry.replaceBatch(provider, [
215
+ { definition: makeDefinition(newsId, 1, "News") },
216
+ { definition: makeDefinition(hubId, 2, "Hub") },
217
+ ]);
218
+ expect(registry.getExposedContracts(hubId, 2, provider)).toEqual([]);
219
+ });
220
+ it("refuses an exposure the batch doesn't provide, of itself, or twice", () => {
221
+ const registry = new registry_1.ContractRegistry();
222
+ const hub = (exposes) => [
223
+ { definition: makeDefinition(newsId, 1, "News") },
224
+ { definition: makeDefinition(hubId, 2, "Hub"), exposes },
225
+ ];
226
+ expect(registry.validateBatch(provider, hub([{ contractId: (0, utils_1.newid)(), version: 1 }]))).toEqual(expect.objectContaining({
227
+ valid: false,
228
+ errors: [expect.objectContaining({ message: expect.stringMatching(/doesn't provide/) })],
229
+ }));
230
+ expect(registry.validateBatch(provider, hub([{ contractId: hubId, version: 2 }])).errors[0].message).toMatch(/can't expose itself/);
231
+ expect(registry.validateBatch(provider, hub([
232
+ { contractId: newsId, version: 1 },
233
+ { contractId: newsId, version: 1 },
234
+ ])).errors[0].message).toMatch(/twice/);
235
+ expect(registry.registerBatch(provider, hub([{ contractId: newsId, version: 2 }])).valid).toBe(false);
236
+ expect(registry.getRegisteredProviderIds(newsId, 1)).toEqual([]);
237
+ });
238
+ });
@@ -0,0 +1,11 @@
1
+ import type { IToolInstance } from "../../data/tools";
2
+ import { type ContractToolDispatch } from "../contract-proxy";
3
+ /**
4
+ * The host's `dispatchTool`, for tests: runs `tools` as the provider of `contractId` v1 with the
5
+ * frame the calling tool's provider bound, so each target is admitted as the host would admit
6
+ * it. Targets can dispatch further through the same bridge.
7
+ *
8
+ * @param contractId - The contract the targets are provided under.
9
+ * @param tools - The target tools, by name.
10
+ */
11
+ export declare function targetDispatch(contractId: string, tools: Record<string, IToolInstance>): ContractToolDispatch;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.targetDispatch = targetDispatch;
4
+ const contract_proxy_1 = require("../contract-proxy");
5
+ /**
6
+ * The host's `dispatchTool`, for tests: runs `tools` as the provider of `contractId` v1 with the
7
+ * frame the calling tool's provider bound, so each target is admitted as the host would admit
8
+ * it. Targets can dispatch further through the same bridge.
9
+ *
10
+ * @param contractId - The contract the targets are provided under.
11
+ * @param tools - The target tools, by name.
12
+ */
13
+ function targetDispatch(contractId, tools) {
14
+ const dispatch = (target, args, context) => provider({
15
+ contractId: target.contractId,
16
+ version: target.version,
17
+ member: "tool",
18
+ name: target.toolName,
19
+ operation: "run",
20
+ args: [args],
21
+ }, context);
22
+ const provider = (0, contract_proxy_1.createContractProvider)({ contractId, version: 1, tools, forwardCallContext: true }, { dispatchTool: dispatch });
23
+ return dispatch;
24
+ }
@@ -203,9 +203,11 @@ test("validates arguments, converts dates, and forwards original nested objects
203
203
  expect(forwarded).toEqual({ ...args, when: new Date(args.when) });
204
204
  expect(forwarded.object).toBe(object);
205
205
  expect(forwarded).not.toHaveProperty("defaulted");
206
+ await expect(invoke({ ...args, defaulted: null })).resolves.toBe("ok");
207
+ expect(f.endpoint.handleCall.mock.calls[1][0].args[0]).not.toHaveProperty("defaulted");
206
208
  expect(args.when).toBe("2026-10-16");
207
209
  await expect(invoke({ ...args, when: "not-a-date" })).rejects.toThrow("argument validation");
208
- expect(f.endpoint.dispose).toHaveBeenCalledTimes(1);
210
+ expect(f.endpoint.dispose).toHaveBeenCalledTimes(2);
209
211
  });
210
212
  test("selects strict, preferred and fallback providers and observes active changes under the same ID", async () => {
211
213
  const f = fixture();
@@ -252,6 +254,40 @@ test("host alsoImplements validates the requested v1 schema and resolves v2 code
252
254
  f.host(f.provider, 2);
253
255
  expect((0, tool_catalog_service_1.describeToolCatalogRow)(f.dataContext, f.row).provider?.implementation?.version).toBe(2);
254
256
  await expect((0, tool_catalog_service_1.invokeToolCatalogTool)(f.dataContext, { toolId: f.row.toolId, args: { value: "v1", new: "not a v2 number" } }, f.context)).resolves.toBe("ok");
257
+ // By name, the v1 row and the v2 row run the same tool: the v2 row is chosen, not "ambiguous".
258
+ const v2Target = { ...f.target, version: 2 };
259
+ const v2Row = {
260
+ ...f.row,
261
+ toolId: (0, tool_catalog_identity_1.deriveToolCatalogId)(v2Target),
262
+ contractVersion: 2,
263
+ };
264
+ f.rows.set(v2Row.toolId, v2Row);
265
+ await expect((0, tool_catalog_service_1.findToolCatalogEntry)(f.dataContext, { toolIdOrName: "run", runnableOnly: true })).resolves.toMatchObject({ toolId: v2Row.toolId });
266
+ });
267
+ test("name lookup keeps aliases ambiguous when the implementing version selects another provider", async () => {
268
+ const f = fixture();
269
+ const newer = { ...f.definition, version: 2 };
270
+ expect(f.registry.register(f.provider, newer, [
271
+ { contractId: f.target.contractId, version: 1, definition: f.definition },
272
+ ]).valid).toBe(true);
273
+ f.host(f.provider, 2);
274
+ const other = (0, utils_1.newid)();
275
+ expect(f.registry.register(other, newer).valid).toBe(true);
276
+ f.host(other, 2);
277
+ expect(f.registry.setActiveProvider(f.target.contractId, 2, other)).toBe(true);
278
+ const v2Row = {
279
+ ...f.row,
280
+ toolId: (0, tool_catalog_identity_1.deriveToolCatalogId)({ ...f.target, version: 2 }),
281
+ contractVersion: 2,
282
+ };
283
+ f.rows.set(v2Row.toolId, v2Row);
284
+ await expect((0, tool_catalog_service_1.findToolCatalogEntry)(f.dataContext, { toolIdOrName: "run", runnableOnly: true })).rejects.toThrow('Ambiguous tool "run": matched 2 tools');
285
+ for (const [row, provider] of [
286
+ [f.row, f.provider],
287
+ [v2Row, other],
288
+ ]) {
289
+ await expect((0, tool_catalog_service_1.findToolCatalogEntry)(f.dataContext, { toolIdOrName: row.toolId, runnableOnly: true })).resolves.toMatchObject({ provider: { activePackageId: provider } });
290
+ }
255
291
  });
256
292
  test("rejects self targets, unresolved and quarantined rows, and never uses legacy invocation", async () => {
257
293
  const f = fixture();
@@ -465,13 +465,22 @@ describe("per-context catalog lifecycle", () => {
465
465
  await expect(f.publish()).rejects.toThrow(/identity cannot change/);
466
466
  await f.catalog.dispose();
467
467
  });
468
- test("alsoImplements indexes only older public members, and removal retains history", async () => {
468
+ test.each([
469
+ false,
470
+ true,
471
+ ])("alsoImplements indexes only older public members, and removal retains history (carried=%s)", async (carried) => {
469
472
  const f = fixture();
470
473
  const provider = (0, utils_1.newid)();
471
474
  const older = definition(["old"]);
472
475
  const newer = definition(["old", "new"], 2);
473
- f.registry.register((0, utils_1.newid)(), older);
474
- const regs = [{ definition: newer, alsoImplements: [{ contractId, version: 1 }] }];
476
+ if (!carried)
477
+ f.registry.register((0, utils_1.newid)(), older);
478
+ const regs = [
479
+ {
480
+ definition: newer,
481
+ alsoImplements: [{ contractId, version: 1, ...(carried ? { definition: older } : {}) }],
482
+ },
483
+ ];
475
484
  await f.catalog.publish(provider, regs, async () => { }, () => f.registry.replaceBatch(provider, regs));
476
485
  expect([...f.rows.values()]
477
486
  .filter((r) => r.contractId === contractId)
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const zod_1 = require("zod");
4
+ const types_1 = require("../../data/orm/types");
3
5
  const field_type_1 = require("../../types/field-type");
4
6
  const utils_1 = require("../../utils");
5
- const types_1 = require("../types");
7
+ const types_2 = require("../types");
6
8
  const validate_1 = require("../validate");
7
9
  const DEFAULT_CONTRACT_ID = (0, utils_1.newid)();
8
10
  /** Helper to build a minimal contract definition for tests. */
@@ -666,13 +668,181 @@ describe("validateImmutability", () => {
666
668
  expect(result.errors[0].message).toContain("frozen");
667
669
  });
668
670
  });
671
+ describe("validateImmutability compares shapes, not text", () => {
672
+ const tool = (inputFields) => ({
673
+ name: "setMode",
674
+ usageDescription: "Set the mode.",
675
+ inputFields,
676
+ outputFields: [{ name: "ok", type: field_type_1.FieldType.boolean }],
677
+ });
678
+ const mode = { name: "mode", type: field_type_1.FieldType.string, enumValues: ["on", "off"] };
679
+ const note = { name: "note", type: field_type_1.FieldType.string, optional: true, description: "A note." };
680
+ const frozen = makeContract({
681
+ tools: [tool([mode, note])],
682
+ observables: [
683
+ {
684
+ name: "state",
685
+ description: "The state.",
686
+ valueType: field_type_1.FieldType.object,
687
+ value: { type: field_type_1.FieldType.object, fields: [{ name: "on", type: field_type_1.FieldType.boolean }] },
688
+ writable: false,
689
+ },
690
+ ],
691
+ });
692
+ it("allows new descriptions and any member, field, or key order", () => {
693
+ const incoming = makeContract({
694
+ observables: [
695
+ {
696
+ writable: false,
697
+ value: { fields: [{ type: field_type_1.FieldType.boolean, name: "on" }], type: field_type_1.FieldType.object },
698
+ valueType: field_type_1.FieldType.object,
699
+ description: "Deprecated: use v2.",
700
+ name: "state",
701
+ },
702
+ ],
703
+ tools: [
704
+ {
705
+ ...tool([
706
+ { ...note, description: "Don't use this." },
707
+ { ...mode, enumValues: ["off", "on"] },
708
+ ]),
709
+ usageDescription: "Deprecated: use v2.",
710
+ },
711
+ ],
712
+ });
713
+ expect((0, validate_1.validateImmutability)(frozen, incoming).valid).toBe(true);
714
+ });
715
+ it("matches a shape built before fields, enumValues, and value existed", () => {
716
+ const older = makeContract({
717
+ tools: [
718
+ tool([
719
+ { name: "mode", type: field_type_1.FieldType.string },
720
+ { name: "note", type: field_type_1.FieldType.string, optional: true },
721
+ ]),
722
+ ],
723
+ observables: [
724
+ { name: "state", description: "", valueType: field_type_1.FieldType.object, writable: false },
725
+ ],
726
+ });
727
+ expect((0, validate_1.validateImmutability)(frozen, older).valid).toBe(true);
728
+ expect((0, validate_1.validateImmutability)(older, frozen).valid).toBe(true);
729
+ });
730
+ it("ignores duplicate enum entries when comparing sets", () => {
731
+ const incoming = makeContract({
732
+ tools: [tool([{ ...mode, enumValues: ["off", "on", "on"] }, note])],
733
+ observables: frozen.observables,
734
+ });
735
+ expect((0, validate_1.validateImmutability)(frozen, incoming).valid).toBe(true);
736
+ expect((0, validate_1.validateImmutability)(incoming, frozen).valid).toBe(true);
737
+ });
738
+ it("matches unchanged legacy default factories across schema rebuilds and JSON storage", () => {
739
+ const factory = jest.fn(() => "generated");
740
+ const legacy = () => makeContract({
741
+ tables: [
742
+ {
743
+ name: "Records",
744
+ description: "",
745
+ primaryKeyName: "id",
746
+ fields: (0, types_1.schemaToFields)(zod_1.z.object({
747
+ id: zod_1.z.string().default(() => factory()),
748
+ label: zod_1.z.string().default("untitled"),
749
+ })),
750
+ },
751
+ ],
752
+ });
753
+ const first = legacy();
754
+ const rebuilt = legacy();
755
+ const stored = JSON.parse(JSON.stringify(first));
756
+ expect((0, validate_1.validateImmutability)(first, rebuilt).valid).toBe(true);
757
+ expect((0, validate_1.validateImmutability)(stored, rebuilt).valid).toBe(true);
758
+ expect((0, validate_1.validateImmutability)(rebuilt, stored).valid).toBe(true);
759
+ expect(factory).not.toHaveBeenCalled();
760
+ });
761
+ it("compares default objects as data, including descriptions and array order", () => {
762
+ const contract = (defaultValue) => makeContract({
763
+ tools: [tool([{ ...mode, defaultValue }])],
764
+ });
765
+ const first = contract({ description: "data", values: ["a", "b"] });
766
+ expect((0, validate_1.validateImmutability)(first, contract({ values: ["a", "b"], description: "data" })).valid).toBe(true);
767
+ expect((0, validate_1.validateImmutability)(first, contract({ description: "changed", values: ["a", "b"] })).valid).toBe(false);
768
+ expect((0, validate_1.validateImmutability)(first, contract({ description: "data", values: ["b", "a"] })).valid).toBe(false);
769
+ });
770
+ it.each([
771
+ ["an enum value", tool([{ ...mode, enumValues: ["on"] }, note])],
772
+ ["optional", tool([mode, { ...note, optional: false }])],
773
+ ["a default value", tool([{ ...mode, defaultValue: "on" }, note])],
774
+ ["a type", tool([mode, { ...note, type: field_type_1.FieldType.number }])],
775
+ ])("rejects a changed %s", (_change, changed) => {
776
+ const result = (0, validate_1.validateImmutability)(frozen, makeContract({ tools: [changed], observables: frozen.observables }));
777
+ expect(result.valid).toBe(false);
778
+ });
779
+ it("rejects a changed observable value shape when both have one", () => {
780
+ const [state] = frozen.observables;
781
+ const incoming = makeContract({
782
+ tools: frozen.tools,
783
+ observables: [
784
+ {
785
+ ...state,
786
+ value: { type: field_type_1.FieldType.object, fields: [{ name: "on", type: field_type_1.FieldType.string }] },
787
+ },
788
+ ],
789
+ });
790
+ expect((0, validate_1.validateImmutability)(frozen, incoming).valid).toBe(false);
791
+ });
792
+ it("ignores a tool's policy, which isn't part of its shape", () => {
793
+ const tool = {
794
+ name: "run",
795
+ usageDescription: "Run.",
796
+ inputFields: [{ name: "id", type: field_type_1.FieldType.id }],
797
+ outputFields: [],
798
+ };
799
+ const frozen = makeContract({ tools: [{ ...tool, remoteAccessLevel: 40 }] });
800
+ const incoming = makeContract({
801
+ tools: [{ ...tool, remoteAccessLevel: 60, requiresWorkflowRun: true }],
802
+ });
803
+ expect((0, validate_1.validateImmutability)(frozen, incoming)).toEqual({ valid: true, errors: [] });
804
+ });
805
+ it("names what changed", () => {
806
+ const field = { name: "id", type: field_type_1.FieldType.id };
807
+ const tool = (name, inputFields = [field]) => ({
808
+ name,
809
+ usageDescription: name,
810
+ inputFields,
811
+ outputFields: [],
812
+ });
813
+ const frozen = makeContract({ tools: [tool("keep"), tool("change"), tool("drop")] });
814
+ const incoming = makeContract({
815
+ tools: [tool("keep"), tool("change", [{ ...field, type: field_type_1.FieldType.string }]), tool("new")],
816
+ });
817
+ expect((0, validate_1.contractShapeChanges)(frozen, incoming)).toEqual([
818
+ "tool change changed",
819
+ "tool drop was removed",
820
+ "tool new was added",
821
+ ]);
822
+ expect((0, validate_1.validateImmutability)(frozen, incoming).errors[0].message).toContain("(tool change changed; tool drop was removed; tool new was added)");
823
+ });
824
+ it.each([
825
+ "tables",
826
+ "tools",
827
+ "observables",
828
+ "events",
829
+ ])("does not hide added or changed duplicate %s behind the last member", (kind) => {
830
+ const member = { name: "duplicate", description: "Original" };
831
+ const before = makeContract({ [kind]: [member] });
832
+ const after = makeContract({ [kind]: [{ ...member, extra: true }, member] });
833
+ expect((0, validate_1.validateImmutability)(before, after).valid).toBe(false);
834
+ expect((0, validate_1.validateImmutability)(after, before).valid).toBe(false);
835
+ const changedDuplicate = makeContract({ [kind]: [{ ...member, extra: false }, member] });
836
+ expect((0, validate_1.validateImmutability)(after, changedDuplicate).valid).toBe(false);
837
+ });
838
+ });
669
839
  describe("validateAlsoImplements", () => {
670
840
  const contractStore = new Map();
671
841
  const TASKS_CONTRACT_ID = (0, utils_1.newid)();
672
842
  const FANCY_TASKS_CONTRACT_ID = (0, utils_1.newid)();
673
843
  const NONEXISTENT_ID = (0, utils_1.newid)();
674
844
  function getContract(id, ver) {
675
- return contractStore.get((0, types_1.contractKey)(id, ver));
845
+ return contractStore.get((0, types_2.contractKey)(id, ver));
676
846
  }
677
847
  beforeEach(() => {
678
848
  contractStore.clear();
@@ -693,7 +863,7 @@ describe("validateAlsoImplements", () => {
693
863
  },
694
864
  ],
695
865
  });
696
- contractStore.set((0, types_1.contractKey)(TASKS_CONTRACT_ID, 1), targetV1);
866
+ contractStore.set((0, types_2.contractKey)(TASKS_CONTRACT_ID, 1), targetV1);
697
867
  const impl = makeContract({
698
868
  contractId: FANCY_TASKS_CONTRACT_ID,
699
869
  version: 1,
@@ -745,8 +915,8 @@ describe("validateAlsoImplements", () => {
745
915
  },
746
916
  ],
747
917
  });
748
- contractStore.set((0, types_1.contractKey)(TASKS_CONTRACT_ID, 1), v1);
749
- contractStore.set((0, types_1.contractKey)(TASKS_CONTRACT_ID, 2), v2);
918
+ contractStore.set((0, types_2.contractKey)(TASKS_CONTRACT_ID, 1), v1);
919
+ contractStore.set((0, types_2.contractKey)(TASKS_CONTRACT_ID, 2), v2);
750
920
  // Implementation satisfies both: has all fields from both versions.
751
921
  // 'status' is optional so v1 consumers can still write valid rows.
752
922
  const impl = makeContract({
@@ -794,8 +964,8 @@ describe("validateAlsoImplements", () => {
794
964
  },
795
965
  ],
796
966
  });
797
- contractStore.set((0, types_1.contractKey)(TASKS_CONTRACT_ID, 1), v1);
798
- contractStore.set((0, types_1.contractKey)(TASKS_CONTRACT_ID, 2), v2);
967
+ contractStore.set((0, types_2.contractKey)(TASKS_CONTRACT_ID, 1), v1);
968
+ contractStore.set((0, types_2.contractKey)(TASKS_CONTRACT_ID, 2), v2);
799
969
  // Implementation only satisfies v1 (missing 'assignee')
800
970
  const impl = makeContract({
801
971
  tables: [
@@ -5,19 +5,10 @@ const contract_proxy_1 = require("../contract-proxy");
5
5
  test("only the directly invoked tool receives workflow capability", async () => {
6
6
  const workflow = { dataContextId: "group" };
7
7
  const nested = jest.fn();
8
- const action = jest.fn((_args, context) => {
9
- expect(context.workflow).toBeUndefined();
10
- expect(context.callContext.workflow).toBeUndefined();
11
- expect(context.incomingCallContext.workflow).toBeUndefined();
12
- });
13
8
  const resolution = {
14
9
  contractId: "contract",
15
10
  version: 1,
16
11
  forwardCallContext: true,
17
- actionAliases: { do: "action" },
18
- actionTools: {
19
- action: { tool: { name: "action", usageDescription: "", accessLevel: 0 }, toolFn: action },
20
- },
21
12
  tools: {
22
13
  run: {
23
14
  tool: { name: "run", usageDescription: "", accessLevel: 0 },
@@ -26,7 +17,6 @@ test("only the directly invoked tool receives workflow capability", async () =>
26
17
  expect(context.callContext.workflow).toBeUndefined();
27
18
  expect(context.incomingCallContext.workflow).toBeUndefined();
28
19
  await context.dispatchTool({ contractId: "other", version: 1, toolName: "target" }, {});
29
- await context.dispatchAction("do", {});
30
20
  },
31
21
  },
32
22
  },
@@ -51,7 +41,6 @@ test("only the directly invoked tool receives workflow capability", async () =>
51
41
  args: [{}],
52
42
  }, { dataContext, callerUserId: "u", workflow });
53
43
  expect(nested.mock.calls[0][2].workflow).toBeUndefined();
54
- expect(action).toHaveBeenCalled();
55
44
  expect(() => (0, contract_proxy_1.requireWorkflowRunContext)({ dataContextId: "other", workflow }, "run")).toThrow("same data context");
56
45
  });
57
46
  test("workflow policy is retained by shape extraction", () => {
@@ -2,7 +2,7 @@ import type { ITableDefinition } from "../data/orm/table-definitions.type";
2
2
  import type { IContractConsumer, IContractConsumerBase } from "./contract-proxy";
3
3
  import type { IExtractableTableDef, IExtractableToolInstance } from "./extract";
4
4
  import type { IToolCatalogMetadata } from "./tool-catalog";
5
- import type { IAlsoImplementsDeclaration, IConsumedContractHandle, IConsumedContractOptions, IContractDefinition, IContractEvent, IContractObservable, IPackageDefinitionResult, IRemoteContractHandle } from "./types";
5
+ import type { IAlsoImplementsDeclaration, IConsumedContractHandle, IConsumedContractOptions, IContractDefinition, IContractEvent, IContractObservable, IExposedContract, IPackageDefinitionResult, IRemoteContractHandle } from "./types";
6
6
  /**
7
7
  * Scoped builder for a single contract within a package. Returned by
8
8
  * `PackageBuilder.contract()`.
@@ -17,6 +17,7 @@ export declare class ContractBuilder {
17
17
  private _observables;
18
18
  private _events;
19
19
  private _alsoImplements;
20
+ private _exposes;
20
21
  private warnedAboutToolInstances;
21
22
  /** Full table definitions for this contract (used by the install flow). */
22
23
  private _tableDefinitions;
@@ -50,10 +51,20 @@ export declare class ContractBuilder {
50
51
  from: number;
51
52
  to: number;
52
53
  }): void;
54
+ /**
55
+ * Expose another contract this package provides through this one: this contract's consumers
56
+ * may call that contract's tools on this provider without consuming it. Use it when this
57
+ * contract is a hub's, such as Voice Skill.
58
+ *
59
+ * @param contractId - A contract this package also provides.
60
+ * @param version - Its version.
61
+ */
62
+ exposes(contractId: string, version: number): void;
53
63
  /** @internal Build the contract definition by extracting shapes. */
54
64
  _build(): {
55
65
  definition: IContractDefinition;
56
66
  alsoImplements: IAlsoImplementsDeclaration[];
67
+ exposes: IExposedContract[];
57
68
  toolMetadata: IToolCatalogMetadata[];
58
69
  tableDefinitions: ITableDefinition[];
59
70
  };
@@ -141,8 +152,11 @@ export declare class PackageBuilder {
141
152
  * @param contractId - Must be a valid peer ID (25-char alphanumeric from `newid()`).
142
153
  * @param version - Positive integer version number.
143
154
  * @param name - Human-readable contract name. This is the meaningful identifier that creators can change over time.
155
+ * @param description - What the contract is for, for people and models reading it. Hubs such
156
+ * as Voice Hub show it for a contract a provider exposes. A description may change at any
157
+ * time, also on a frozen contract.
144
158
  */
145
- contract(contractId: string, version: number, name: string): ContractBuilder;
159
+ contract(contractId: string, version: number, name: string, description?: string): ContractBuilder;
146
160
  /**
147
161
  * Declare a dependency on another package's contract.
148
162
  *
@@ -25,6 +25,7 @@ class ContractBuilder {
25
25
  _observables = [];
26
26
  _events = [];
27
27
  _alsoImplements = [];
28
+ _exposes = [];
28
29
  warnedAboutToolInstances = false;
29
30
  /** Full table definitions for this contract (used by the install flow). */
30
31
  _tableDefinitions = [];
@@ -90,6 +91,23 @@ class ContractBuilder {
90
91
  }
91
92
  this._alsoImplements.push({ contractId: targetContractId, version: versionOrRange });
92
93
  }
94
+ /**
95
+ * Expose another contract this package provides through this one: this contract's consumers
96
+ * may call that contract's tools on this provider without consuming it. Use it when this
97
+ * contract is a hub's, such as Voice Skill.
98
+ *
99
+ * @param contractId - A contract this package also provides.
100
+ * @param version - Its version.
101
+ */
102
+ exposes(contractId, version) {
103
+ if (!(0, utils_1.isid)(contractId)) {
104
+ throw new Error(`exposes contractId must be a valid peer ID (25-char alphanumeric), got "${contractId}"`);
105
+ }
106
+ if (!Number.isInteger(version) || version < 1) {
107
+ throw new Error(`exposes version must be a positive integer, got ${version}`);
108
+ }
109
+ this._exposes.push({ contractId, version });
110
+ }
93
111
  /** @internal Build the contract definition by extracting shapes. */
94
112
  _build() {
95
113
  return {
@@ -105,6 +123,7 @@ class ContractBuilder {
105
123
  events: [...this._events],
106
124
  },
107
125
  alsoImplements: [...this._alsoImplements],
126
+ exposes: [...this._exposes],
108
127
  toolMetadata: this._tools.flatMap(({ tool }) => {
109
128
  const { isAssistantRunner, configSchema, detailedDescription } = tool;
110
129
  return isAssistantRunner !== undefined ||
@@ -249,8 +268,11 @@ class PackageBuilder {
249
268
  * @param contractId - Must be a valid peer ID (25-char alphanumeric from `newid()`).
250
269
  * @param version - Positive integer version number.
251
270
  * @param name - Human-readable contract name. This is the meaningful identifier that creators can change over time.
271
+ * @param description - What the contract is for, for people and models reading it. Hubs such
272
+ * as Voice Hub show it for a contract a provider exposes. A description may change at any
273
+ * time, also on a frozen contract.
252
274
  */
253
- contract(contractId, version, name) {
275
+ contract(contractId, version, name, description) {
254
276
  if (!(0, utils_1.isid)(contractId)) {
255
277
  throw new Error(`contractId must be a valid peer ID (25-char alphanumeric), got "${contractId}"`);
256
278
  }
@@ -261,7 +283,7 @@ class PackageBuilder {
261
283
  if (existing) {
262
284
  throw new Error(`Duplicate contract: ${contractId} v${version} is already defined in this package`);
263
285
  }
264
- const builder = new ContractBuilder(contractId, version, name);
286
+ const builder = new ContractBuilder(contractId, version, name, description);
265
287
  this._contracts.push(builder);
266
288
  return builder;
267
289
  }
@@ -330,17 +352,35 @@ class PackageBuilder {
330
352
  }
331
353
  const contracts = [];
332
354
  const alsoImplementsMap = new Map();
355
+ const exposesMap = new Map();
333
356
  const allToolMetadata = [];
334
357
  const allTableDefinitions = [];
335
358
  for (const cb of this._contracts) {
336
- const { definition, alsoImplements, toolMetadata, tableDefinitions } = cb._build();
359
+ const { definition, alsoImplements, exposes, toolMetadata, tableDefinitions } = cb._build();
337
360
  contracts.push(definition);
338
361
  if (alsoImplements.length > 0) {
339
362
  alsoImplementsMap.set((0, types_1.contractKey)(cb.contractId, cb.version), alsoImplements);
340
363
  }
364
+ if (exposes.length > 0) {
365
+ exposesMap.set((0, types_1.contractKey)(cb.contractId, cb.version), exposes);
366
+ }
341
367
  allToolMetadata.push(...toolMetadata);
342
368
  allTableDefinitions.push(...tableDefinitions);
343
369
  }
370
+ const providedKeys = new Set(contracts.map((contract) => (0, types_1.contractKey)(contract.contractId, contract.version)));
371
+ for (const [hub, exposes] of exposesMap) {
372
+ const seen = new Set();
373
+ for (const target of exposes) {
374
+ const key = (0, types_1.contractKey)(target.contractId, target.version);
375
+ if (key === hub)
376
+ throw new Error(`Contract ${hub} can't expose itself`);
377
+ if (seen.has(key))
378
+ throw new Error(`Contract ${hub} exposes ${key} twice`);
379
+ if (!providedKeys.has(key))
380
+ throw new Error(`Contract ${hub} exposes ${key}, which the package doesn't provide`);
381
+ seen.add(key);
382
+ }
383
+ }
344
384
  if (contracts.length === 0 && allTableDefinitions.length > 0) {
345
385
  throw new Error("Tables must be declared on a contract (use pkg.contract(...), then assign .tables or .tableDefinitions on that builder). UI-only packages omit contracts and do not register tables.");
346
386
  }
@@ -351,6 +391,7 @@ class PackageBuilder {
351
391
  consumes: [...this._consumes],
352
392
  remotes: [...this._remotes],
353
393
  alsoImplements: alsoImplementsMap,
394
+ exposes: exposesMap,
354
395
  assistants: [...this.assistants],
355
396
  appNavs: [...this.appNavs],
356
397
  ...(allToolMetadata.length ? { toolMetadata: allToolMetadata } : {}),