@peers-app/peers-sdk 0.23.2 → 0.23.5

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 (68) hide show
  1. package/dist/context/data-context.d.ts +36 -5
  2. package/dist/context/data-context.js +44 -9
  3. package/dist/context/user-context.d.ts +44 -0
  4. package/dist/context/user-context.js +91 -12
  5. package/dist/contracts/__tests__/contract-provider-router.test.js +26 -0
  6. package/dist/contracts/__tests__/contract-resolvers.test.js +44 -0
  7. package/dist/contracts/__tests__/registry.test.js +104 -0
  8. package/dist/contracts/contract-provider-router.d.ts +21 -0
  9. package/dist/contracts/contract-provider-router.js +31 -5
  10. package/dist/contracts/contract-proxy.d.ts +3 -0
  11. package/dist/contracts/contract-proxy.js +3 -0
  12. package/dist/contracts/contract-resolvers.d.ts +21 -3
  13. package/dist/contracts/contract-resolvers.js +105 -32
  14. package/dist/contracts/index.d.ts +3 -3
  15. package/dist/contracts/registry.d.ts +59 -0
  16. package/dist/contracts/registry.js +155 -25
  17. package/dist/contracts/system-contracts.js +3 -1
  18. package/dist/data/group-share.d.ts +13 -0
  19. package/dist/data/group-share.js +32 -8
  20. package/dist/data/group-share.test.d.ts +1 -0
  21. package/dist/data/group-share.test.js +110 -0
  22. package/dist/data/index.d.ts +1 -0
  23. package/dist/data/index.js +1 -0
  24. package/dist/data/orm/types.d.ts +19 -0
  25. package/dist/data/orm/types.js +11 -0
  26. package/dist/data/package-table-identity.d.ts +46 -0
  27. package/dist/data/package-table-identity.js +71 -0
  28. package/dist/data/package-table-identity.test.d.ts +1 -0
  29. package/dist/data/package-table-identity.test.js +46 -0
  30. package/dist/data/package-version-resolver.d.ts +13 -0
  31. package/dist/data/package-version-resolver.js +142 -6
  32. package/dist/data/package-version-resolver.test.js +42 -0
  33. package/dist/data/persistent-var-identity.d.ts +37 -0
  34. package/dist/data/persistent-var-identity.js +69 -0
  35. package/dist/data/persistent-vars.d.ts +3 -5
  36. package/dist/data/persistent-vars.js +25 -13
  37. package/dist/data/persistent-vars.package-scope.test.d.ts +1 -0
  38. package/dist/data/persistent-vars.package-scope.test.js +27 -0
  39. package/dist/data/tools.d.ts +3 -3
  40. package/dist/group-invite/group-invite.types.d.ts +15 -5
  41. package/dist/package-loader/contract-package-loader.js +20 -8
  42. package/dist/package-loader/contract-package-loader.test.js +16 -0
  43. package/dist/package-loader/index.d.ts +4 -0
  44. package/dist/package-loader/index.js +4 -0
  45. package/dist/package-loader/isolated-artifact.d.ts +233 -0
  46. package/dist/package-loader/isolated-artifact.js +643 -0
  47. package/dist/package-loader/isolated-artifact.test.d.ts +1 -0
  48. package/dist/package-loader/isolated-artifact.test.js +270 -0
  49. package/dist/package-loader/isolated-persistent-var-gateway.d.ts +20 -0
  50. package/dist/package-loader/isolated-persistent-var-gateway.js +106 -0
  51. package/dist/package-loader/isolated-persistent-var-gateway.test.d.ts +1 -0
  52. package/dist/package-loader/isolated-persistent-var-gateway.test.js +56 -0
  53. package/dist/package-loader/isolated-runtime.d.ts +229 -0
  54. package/dist/package-loader/isolated-runtime.js +37 -0
  55. package/dist/package-loader/isolated-table-gateway.d.ts +16 -0
  56. package/dist/package-loader/isolated-table-gateway.js +375 -0
  57. package/dist/package-loader/isolated-table-gateway.test.d.ts +1 -0
  58. package/dist/package-loader/isolated-table-gateway.test.js +206 -0
  59. package/dist/package-loader/isolated-value.d.ts +14 -0
  60. package/dist/package-loader/isolated-value.js +63 -0
  61. package/dist/package-loader/package-loader.d.ts +123 -4
  62. package/dist/package-loader/package-loader.js +417 -46
  63. package/dist/package-loader/package-loader.test.js +478 -12
  64. package/dist/package-loader/resolve-consumed-contracts.d.ts +3 -3
  65. package/dist/package-loader/resolve-consumed-contracts.js +18 -10
  66. package/dist/utils.d.ts +21 -2
  67. package/dist/utils.js +46 -7
  68. package/package.json +1 -1
@@ -3,29 +3,60 @@ import { EventRegistry, type ITableDependencies, type ITableMetaData, Table, Tab
3
3
  import type { DataSourceFactory } from "../data/orm/table-definitions.type";
4
4
  import { PackageLoader } from "../package-loader/package-loader";
5
5
  import type { UserContext } from "./user-context";
6
+ /**
7
+ * Per-user or per-group workspace: tables, events, and the package loader
8
+ * that installs both legacy and isolated packages into this context.
9
+ *
10
+ * `tableContainer` is created before `packageLoader` so isolated packages that
11
+ * declare provider-owned tables receive a host table gateway at construct time.
12
+ */
6
13
  export declare class DataContext {
7
14
  readonly userContext: UserContext;
8
15
  readonly groupId?: string | undefined;
16
+ private disposePromise?;
17
+ /** Factory that wraps the user-context data source with this group id. */
9
18
  readonly dataSourceFactory: DataSourceFactory;
19
+ /** Live table registry for this personal or group context. */
10
20
  readonly tableContainer: TableContainer;
21
+ /** Loads and unloads packages for this context, including isolated artifacts. */
11
22
  readonly packageLoader: PackageLoader;
23
+ /** In-process event bus scoped to {@link dataContextId}. */
12
24
  readonly eventRegistry: EventRegistry;
25
+ /** Personal user id, or group id when this context is group-scoped. */
13
26
  readonly dataContextId: string;
27
+ /**
28
+ * @param userContext - Owning signed-in user.
29
+ * @param groupId - When set, this context is the named group; otherwise personal.
30
+ */
14
31
  constructor(userContext: UserContext, groupId?: string | undefined);
15
32
  /**
16
- * Call this to indicate that this should be the current data context to be used unless otherwise specified
33
+ * Mark this context as the user's default unless a caller pins another one.
17
34
  */
18
35
  setAsDefault(): void;
19
36
  /**
20
- * Creates table dependencies for the new DI pattern.
21
- * This factory method provides all dependencies a table needs in one object.
37
+ * Build the dependency bag a `Table` constructor needs for this context.
38
+ *
39
+ * @param metaData - Table metadata used to open the data source.
40
+ * @param schema - Optional Zod schema for the table rows.
22
41
  */
23
42
  createTableDependencies(metaData: ITableMetaData, schema?: z.AnyZodObject): ITableDependencies;
24
43
  /**
25
- * Convenience method for creating tables with the new DI pattern.
26
- * This is the preferred way to create custom table classes.
44
+ * Construct a table (or a custom `Table` subclass) bound to this context.
45
+ *
46
+ * @param metaData - Table metadata.
47
+ * @param schema - Optional Zod schema for the table rows.
48
+ * @param TableClass - Table implementation; defaults to {@link Table}.
27
49
  */
28
50
  createTable<T extends {
29
51
  [key: string]: any;
30
52
  }>(metaData: ITableMetaData, schema?: z.AnyZodObject, TableClass?: new (metaData: ITableMetaData, deps: ITableDependencies) => Table<T>): Table<T>;
53
+ /**
54
+ * Dispose package runtimes and release event emitters owned by this context.
55
+ *
56
+ * Repeated calls share one promise so group-removal and application-shutdown
57
+ * cleanup can safely race.
58
+ *
59
+ * @returns A promise that settles after all context resources are released.
60
+ */
61
+ dispose(): Promise<void>;
31
62
  }
@@ -3,37 +3,55 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DataContext = void 0;
4
4
  const orm_1 = require("../data/orm");
5
5
  const package_loader_1 = require("../package-loader/package-loader");
6
+ /**
7
+ * Per-user or per-group workspace: tables, events, and the package loader
8
+ * that installs both legacy and isolated packages into this context.
9
+ *
10
+ * `tableContainer` is created before `packageLoader` so isolated packages that
11
+ * declare provider-owned tables receive a host table gateway at construct time.
12
+ */
6
13
  class DataContext {
7
14
  userContext;
8
15
  groupId;
16
+ disposePromise;
17
+ /** Factory that wraps the user-context data source with this group id. */
9
18
  dataSourceFactory;
19
+ /** Live table registry for this personal or group context. */
10
20
  tableContainer;
21
+ /** Loads and unloads packages for this context, including isolated artifacts. */
11
22
  packageLoader;
23
+ /** In-process event bus scoped to {@link dataContextId}. */
12
24
  eventRegistry;
25
+ /** Personal user id, or group id when this context is group-scoped. */
13
26
  dataContextId;
27
+ /**
28
+ * @param userContext - Owning signed-in user.
29
+ * @param groupId - When set, this context is the named group; otherwise personal.
30
+ */
14
31
  constructor(userContext, groupId) {
15
32
  this.userContext = userContext;
16
33
  this.groupId = groupId;
17
34
  this.dataContextId = groupId || userContext.userId;
18
- // Initialize dependencies in the correct order to avoid circular references
19
35
  this.eventRegistry = new orm_1.EventRegistry(this.dataContextId);
20
- this.packageLoader = new package_loader_1.PackageLoader(this);
21
- // Set up data source factory with group wrapping
22
36
  const baseFactory = userContext.dataSourceFactory;
23
37
  this.dataSourceFactory = (metaData, schema) => baseFactory(metaData, schema, this.groupId);
24
- // Set up table factory and table container
25
38
  const tableFactory = (metaData, schema, TableClass) => this.createTable(metaData, schema, TableClass);
26
39
  this.tableContainer = new orm_1.TableContainer(tableFactory, groupId);
40
+ // PackageLoader reads `tableContainer` in its constructor to wire isolated
41
+ // table persistence. Keep this last.
42
+ this.packageLoader = new package_loader_1.PackageLoader(this);
27
43
  }
28
44
  /**
29
- * Call this to indicate that this should be the current data context to be used unless otherwise specified
45
+ * Mark this context as the user's default unless a caller pins another one.
30
46
  */
31
47
  setAsDefault() {
32
48
  this.userContext.defaultDataContext(this);
33
49
  }
34
50
  /**
35
- * Creates table dependencies for the new DI pattern.
36
- * This factory method provides all dependencies a table needs in one object.
51
+ * Build the dependency bag a `Table` constructor needs for this context.
52
+ *
53
+ * @param metaData - Table metadata used to open the data source.
54
+ * @param schema - Optional Zod schema for the table rows.
37
55
  */
38
56
  createTableDependencies(metaData, schema) {
39
57
  return {
@@ -44,12 +62,29 @@ class DataContext {
44
62
  };
45
63
  }
46
64
  /**
47
- * Convenience method for creating tables with the new DI pattern.
48
- * This is the preferred way to create custom table classes.
65
+ * Construct a table (or a custom `Table` subclass) bound to this context.
66
+ *
67
+ * @param metaData - Table metadata.
68
+ * @param schema - Optional Zod schema for the table rows.
69
+ * @param TableClass - Table implementation; defaults to {@link Table}.
49
70
  */
50
71
  createTable(metaData, schema, TableClass = orm_1.Table) {
51
72
  const deps = this.createTableDependencies(metaData, schema);
52
73
  return new TableClass(metaData, deps);
53
74
  }
75
+ /**
76
+ * Dispose package runtimes and release event emitters owned by this context.
77
+ *
78
+ * Repeated calls share one promise so group-removal and application-shutdown
79
+ * cleanup can safely race.
80
+ *
81
+ * @returns A promise that settles after all context resources are released.
82
+ */
83
+ dispose() {
84
+ this.disposePromise ??= this.packageLoader.dispose().finally(() => {
85
+ this.eventRegistry.clear();
86
+ });
87
+ return this.disposePromise;
88
+ }
54
89
  }
55
90
  exports.DataContext = DataContext;
@@ -18,8 +18,11 @@ export declare class UserContext {
18
18
  readonly loadingPromise: Promise<UserContext>;
19
19
  private personalUserSubscription?;
20
20
  private ownUserSubscription?;
21
+ private groupsSubscription?;
22
+ private observableSubscriptions;
21
23
  private groupUserSyncs;
22
24
  private _currentlyActiveGroupIdPVar?;
25
+ private disposed;
23
26
  constructor(userId: string, dataSourceFactory: DataSourceFactory, ephemeral?: boolean | undefined);
24
27
  private init;
25
28
  /**
@@ -35,6 +38,13 @@ export declare class UserContext {
35
38
  force?: boolean;
36
39
  }): Promise<void>;
37
40
  private loadGroupContexts;
41
+ private removeGroupDataContext;
42
+ /**
43
+ * Return the personal context, or the group context for `groupId`, creating
44
+ * the group context if this user context does not already hold it.
45
+ *
46
+ * @param groupId - Group id, or the current user id for the personal context.
47
+ */
38
48
  getDataContext(groupId: string): DataContext;
39
49
  /** Wait for any pending `currentlyActiveGroupId` pvar write to be flushed to the DB. */
40
50
  waitForActiveGroupPersistence(): Promise<void>;
@@ -44,6 +54,11 @@ export declare class UserContext {
44
54
  * for the user context to be fully loaded before proceeding with standard data access
45
55
  */
46
56
  private loadUserContextObservablesFromDB;
57
+ /**
58
+ * Load the signed-in user's `Users` row from the personal data context.
59
+ *
60
+ * @returns The cached personal user record, or `undefined` if it is missing.
61
+ */
47
62
  getMe(): Promise<{
48
63
  name: string;
49
64
  publicKey: string;
@@ -51,12 +66,41 @@ export declare class UserContext {
51
66
  userId: string;
52
67
  signature?: string | undefined;
53
68
  } | undefined>;
69
+ /**
70
+ * Sign and persist this user's identity, then copy it into every loaded
71
+ * group context and copy group records back into the personal context.
72
+ *
73
+ * @param userId - Signed-in user id.
74
+ * @param keys - Current public/private keypair used to sign the user row.
75
+ * @param me - Optional preloaded personal user row.
76
+ */
54
77
  syncUserAndGroupObjects(userId: string, keys: IPublicPrivateKeys, me?: IUser): Promise<void>;
55
78
  private syncMyUserToAllGroups;
56
79
  private syncMyUserToGroup;
80
+ /**
81
+ * Subscribe to `dataChanged` events for one table across the personal
82
+ * context and every loaded group context.
83
+ *
84
+ * @param table - Table instance or table name.
85
+ * @param handler - Invoked with the event plus the owning data context.
86
+ */
57
87
  subscribeToDataChangedAcrossAllGroups<T extends {
58
88
  [key: string]: any;
59
89
  }>(table: string | Table<T>, handler: (evt: ICrossGroupSubscriptionHandlerArgs<T>) => any): import("../events").ISubscriptionResult;
90
+ /**
91
+ * Dispose resources for the personal context and every loaded group context.
92
+ *
93
+ * The legacy method name is retained for callers that use this during
94
+ * Electron shutdown; disposal now also clears each context's event registry.
95
+ */
96
+ disposeIsolatedRuntimes(): Promise<void>;
97
+ /**
98
+ * Unsubscribe cross-group listeners and dispose isolated package runtimes.
99
+ *
100
+ * Runtime disposal is started but not awaited. Prefer
101
+ * {@link disposeIsolatedRuntimes} when the caller can wait (tests,
102
+ * Electron shutdown).
103
+ */
60
104
  dispose(): void;
61
105
  }
62
106
  /** Event payload supplied to subscriptions spanning every group data context. */
@@ -23,8 +23,11 @@ class UserContext {
23
23
  loadingPromise;
24
24
  personalUserSubscription;
25
25
  ownUserSubscription;
26
+ groupsSubscription;
27
+ observableSubscriptions = [];
26
28
  groupUserSyncs = new Map();
27
29
  _currentlyActiveGroupIdPVar;
30
+ disposed = false;
28
31
  constructor(userId, dataSourceFactory, ephemeral) {
29
32
  this.userId = userId;
30
33
  this.dataSourceFactory = dataSourceFactory;
@@ -43,6 +46,9 @@ class UserContext {
43
46
  if (!this.ephemeral) {
44
47
  await this.loadUserContextObservablesFromDB();
45
48
  }
49
+ if (this.disposed) {
50
+ return this;
51
+ }
46
52
  await this.loadGroupContexts();
47
53
  return this;
48
54
  }
@@ -64,12 +70,14 @@ class UserContext {
64
70
  async loadGroupContexts() {
65
71
  const groupsTable = (0, data_1.Groups)(this.userDataContext);
66
72
  const groups = await groupsTable.list({ disabled: { $ne: true } });
73
+ if (this.disposed) {
74
+ return;
75
+ }
67
76
  this.groupIds(groups.map((g) => g.groupId));
68
- groupsTable.dataChanged.subscribe((evt) => {
77
+ this.groupsSubscription = groupsTable.dataChanged.subscribe((evt) => {
69
78
  const groupId = evt.dataObject.groupId;
70
79
  if (evt.op === "delete" || evt.dataObject.disabled) {
71
- this.groupIds(this.groupIds().filter((id) => id !== groupId));
72
- this.groupDataContexts.delete(groupId);
80
+ this.removeGroupDataContext(groupId);
73
81
  }
74
82
  else {
75
83
  const dataContext = this.getDataContext(groupId);
@@ -91,22 +99,39 @@ class UserContext {
91
99
  this.defaultDataContext(this.userDataContext);
92
100
  }
93
101
  // ensure when currentlyActiveGroupId changes, currentlyActiveGroup is updated and vice versa
94
- this.currentlyActiveGroupId.subscribe(async (groupId) => {
102
+ this.observableSubscriptions.push(this.currentlyActiveGroupId.subscribe(async (groupId) => {
95
103
  const dataContext = this.getDataContext(groupId || this.userId);
96
104
  this.defaultDataContext(dataContext);
97
- });
98
- this.defaultDataContext.subscribe((group) => {
105
+ }), this.defaultDataContext.subscribe((group) => {
99
106
  if (!group) {
100
107
  this.defaultDataContext(this.userDataContext);
101
108
  }
102
109
  else {
103
110
  this.currentlyActiveGroupId(group.dataContextId);
104
111
  }
105
- });
112
+ }));
106
113
  if (!this.currentlyActiveGroupId()) {
107
114
  this.currentlyActiveGroupId(this.defaultDataContext().dataContextId || this.userId);
108
115
  }
109
116
  }
117
+ removeGroupDataContext(groupId) {
118
+ this.groupIds(this.groupIds().filter((id) => id !== groupId));
119
+ const removedContext = this.groupDataContexts.get(groupId);
120
+ this.groupDataContexts.delete(groupId);
121
+ this.groupUserSyncs.delete(groupId);
122
+ if (this.defaultDataContext().dataContextId === groupId) {
123
+ this.defaultDataContext(this.userDataContext);
124
+ }
125
+ void removedContext?.dispose().catch((error) => {
126
+ console.error(`Error disposing removed group context ${groupId}:`, error);
127
+ });
128
+ }
129
+ /**
130
+ * Return the personal context, or the group context for `groupId`, creating
131
+ * the group context if this user context does not already hold it.
132
+ *
133
+ * @param groupId - Group id, or the current user id for the personal context.
134
+ */
110
135
  getDataContext(groupId) {
111
136
  if (!groupId || groupId === this.userId) {
112
137
  return this.userDataContext;
@@ -121,7 +146,11 @@ class UserContext {
121
146
  const groupContext = new data_context_1.DataContext(this, groupId);
122
147
  this.groupDataContexts.set(groupId, groupContext);
123
148
  }
124
- return this.groupDataContexts.get(groupId);
149
+ const dataContext = this.groupDataContexts.get(groupId);
150
+ if (!dataContext) {
151
+ throw new Error(`Failed to create data context for group ${groupId}`);
152
+ }
153
+ return dataContext;
125
154
  }
126
155
  /** Wait for any pending `currentlyActiveGroupId` pvar write to be flushed to the DB. */
127
156
  async waitForActiveGroupPersistence() {
@@ -140,6 +169,9 @@ class UserContext {
140
169
  const vars = await persistentVars.list({
141
170
  name: { $in: ["thisDeviceId", "currentlyActiveGroupId", "reloadPackagesOnPageRefresh"] },
142
171
  });
172
+ if (this.disposed) {
173
+ return;
174
+ }
143
175
  const varDbValues = vars.reduce((acc, curr) => {
144
176
  acc[curr.name] = curr.value?.value;
145
177
  return acc;
@@ -151,7 +183,7 @@ class UserContext {
151
183
  userContext: this,
152
184
  });
153
185
  this.deviceId(deviceIdPVar());
154
- (0, observable_1.linkObservables)(deviceIdPVar, this.deviceId);
186
+ this.observableSubscriptions.push((0, observable_1.linkObservables)(deviceIdPVar, this.deviceId));
155
187
  const currentlyActiveGroupIdPVar = (0, data_1.deviceVar)("currentlyActiveGroupId", {
156
188
  dbValue: varDbValues.currentlyActiveGroupId,
157
189
  defaultValue: "",
@@ -159,24 +191,37 @@ class UserContext {
159
191
  });
160
192
  this._currentlyActiveGroupIdPVar = currentlyActiveGroupIdPVar;
161
193
  this.currentlyActiveGroupId(currentlyActiveGroupIdPVar());
162
- (0, observable_1.linkObservables)(currentlyActiveGroupIdPVar, this.currentlyActiveGroupId);
194
+ this.observableSubscriptions.push((0, observable_1.linkObservables)(currentlyActiveGroupIdPVar, this.currentlyActiveGroupId));
163
195
  const reloadPackagesOnPageRefreshPVar = (0, data_1.deviceVar)("reloadPackagesOnPageRefresh", {
164
196
  defaultValue: false,
165
197
  dbValue: varDbValues.reloadPackagesOnPageRefresh,
166
198
  userContext: this,
167
199
  });
168
200
  this.reloadPackagesOnPageRefresh(reloadPackagesOnPageRefreshPVar());
169
- (0, observable_1.linkObservables)(reloadPackagesOnPageRefreshPVar, this.reloadPackagesOnPageRefresh);
201
+ this.observableSubscriptions.push((0, observable_1.linkObservables)(reloadPackagesOnPageRefreshPVar, this.reloadPackagesOnPageRefresh));
170
202
  await Promise.all([
171
203
  deviceIdPVar.loadingPromise,
172
204
  currentlyActiveGroupIdPVar.loadingPromise,
173
205
  reloadPackagesOnPageRefreshPVar.loadingPromise,
174
206
  ]);
175
207
  }
208
+ /**
209
+ * Load the signed-in user's `Users` row from the personal data context.
210
+ *
211
+ * @returns The cached personal user record, or `undefined` if it is missing.
212
+ */
176
213
  async getMe() {
177
214
  const dbMe = await (0, data_1.Users)(this.userDataContext).get(this.userId, { useCache: true });
178
215
  return dbMe;
179
216
  }
217
+ /**
218
+ * Sign and persist this user's identity, then copy it into every loaded
219
+ * group context and copy group records back into the personal context.
220
+ *
221
+ * @param userId - Signed-in user id.
222
+ * @param keys - Current public/private keypair used to sign the user row.
223
+ * @param me - Optional preloaded personal user row.
224
+ */
180
225
  async syncUserAndGroupObjects(userId, keys, me) {
181
226
  me ??= await this.getMe();
182
227
  if (!me) {
@@ -274,12 +319,21 @@ class UserContext {
274
319
  }
275
320
  }
276
321
  }
322
+ /**
323
+ * Subscribe to `dataChanged` events for one table across the personal
324
+ * context and every loaded group context.
325
+ *
326
+ * @param table - Table instance or table name.
327
+ * @param handler - Invoked with the event plus the owning data context.
328
+ */
277
329
  subscribeToDataChangedAcrossAllGroups(table, handler) {
278
330
  const tableName = typeof table === "string" ? table : table.tableName;
279
331
  const tableEventPrefix = `${tableName}_DataChanged_`;
280
332
  const unsubscribePrefix = (0, events_1.subscribePrefix)(tableEventPrefix);
281
333
  const subscription = (0, events_1.subscribe)((evt) => evt.name.startsWith(tableEventPrefix), async (evt) => {
282
- const dataContextId = evt.name.endsWith("_") ? this.userId : evt.name.split("_").pop();
334
+ const dataContextId = evt.name.endsWith("_")
335
+ ? this.userId
336
+ : (evt.name.split("_").pop() ?? this.userId);
283
337
  const dataContext = this.getDataContext(dataContextId);
284
338
  handler({
285
339
  name: evt.name,
@@ -295,9 +349,34 @@ class UserContext {
295
349
  };
296
350
  return subscription;
297
351
  }
352
+ /**
353
+ * Dispose resources for the personal context and every loaded group context.
354
+ *
355
+ * The legacy method name is retained for callers that use this during
356
+ * Electron shutdown; disposal now also clears each context's event registry.
357
+ */
358
+ async disposeIsolatedRuntimes() {
359
+ await Promise.all([
360
+ this.userDataContext.dispose(),
361
+ ...[...this.groupDataContexts.values()].map((context) => context.dispose()),
362
+ ]);
363
+ }
364
+ /**
365
+ * Unsubscribe cross-group listeners and dispose isolated package runtimes.
366
+ *
367
+ * Runtime disposal is started but not awaited. Prefer
368
+ * {@link disposeIsolatedRuntimes} when the caller can wait (tests,
369
+ * Electron shutdown).
370
+ */
298
371
  dispose() {
372
+ this.disposed = true;
373
+ this.groupsSubscription?.unsubscribe();
299
374
  this.personalUserSubscription?.unsubscribe();
300
375
  this.ownUserSubscription?.unsubscribe();
376
+ for (const subscription of this.observableSubscriptions.splice(0)) {
377
+ subscription.dispose();
378
+ }
379
+ void this.disposeIsolatedRuntimes();
301
380
  }
302
381
  }
303
382
  exports.UserContext = UserContext;
@@ -64,6 +64,32 @@ describe("contract provider router", () => {
64
64
  expect(resolveEndpoint).not.toHaveBeenCalled();
65
65
  router.dispose();
66
66
  });
67
+ it("keys endpoints by provider and invalidates one provider without trusting call payload", async () => {
68
+ const [consumerTransport, providerTransport] = (0, index_1.inProcessTransportPair)();
69
+ let activeProvider = "provider-a";
70
+ const created = [];
71
+ const router = (0, index_1.createContractProviderRouter)(providerTransport, {
72
+ authorizeCall: () => ({ dataContextId: "personal-context" }),
73
+ resolveProviderPackageId: () => activeProvider,
74
+ resolveEndpoint: (request) => {
75
+ const providerPackageId = request.providerPackageId ?? "missing";
76
+ const endpoint = makeEndpoint(providerPackageId);
77
+ created.push({ providerPackageId, endpoint });
78
+ return endpoint;
79
+ },
80
+ });
81
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeCall())).resolves.toBe("provider-a:list");
82
+ activeProvider = "provider-b";
83
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeCall())).resolves.toBe("provider-b:list");
84
+ expect(created).toHaveLength(2);
85
+ router.invalidateProvider("provider-a");
86
+ expect(created[0].endpoint.dispose).toHaveBeenCalledTimes(1);
87
+ expect(created[1].endpoint.dispose).not.toHaveBeenCalled();
88
+ activeProvider = "provider-a";
89
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeCall())).resolves.toBe("provider-a:list");
90
+ expect(created).toHaveLength(3);
91
+ router.dispose();
92
+ });
67
93
  it("ignores peer-supplied context in favor of trusted host context", async () => {
68
94
  const [consumerTransport, providerTransport] = (0, index_1.inProcessTransportPair)();
69
95
  const trustedContext = {
@@ -259,6 +259,22 @@ describe("local user-context contract routing", () => {
259
259
  expect(setup.getDataContext).not.toHaveBeenCalledWith(unknownId);
260
260
  router.dispose();
261
261
  });
262
+ it("prefers an isolated package endpoint over an in-process resolver", async () => {
263
+ const setup = setupLocalRouter();
264
+ const isolatedHandle = jest.fn(async () => "from-isolated");
265
+ setup.userContext.userDataContext.packageLoader = {
266
+ resolveIsolatedEndpoint: jest.fn(async () => ({
267
+ handleCall: isolatedHandle,
268
+ dispose() { },
269
+ })),
270
+ };
271
+ const router = (0, index_1.createLocalUserContextContractProviderRouter)(setup.providerTransport, {
272
+ getUserContext: async () => setup.userContext,
273
+ });
274
+ await expect(setup.consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeToolCall(setup.contractId))).resolves.toBe("from-isolated");
275
+ expect(isolatedHandle).toHaveBeenCalled();
276
+ router.dispose();
277
+ });
262
278
  it("enforces an optional contract allowlist before resolving a provider", async () => {
263
279
  const setup = setupLocalRouter();
264
280
  const router = (0, index_1.createLocalUserContextContractProviderRouter)(setup.providerTransport, {
@@ -305,3 +321,31 @@ describe("local user-context contract routing", () => {
305
321
  router.dispose();
306
322
  });
307
323
  });
324
+ describe("provider-scoped contract resolvers", () => {
325
+ it("prefers the selected provider and preserves the unscoped fallback", () => {
326
+ const contractId = (0, utils_1.newid)();
327
+ const providerA = (0, utils_1.newid)();
328
+ const providerB = (0, utils_1.newid)();
329
+ const unscoped = jest.fn(() => ({ contractId, version: 1 }));
330
+ const scopedA = jest.fn(() => ({ contractId, version: 1 }));
331
+ (0, index_1.registerContractResolver)(contractId, 1, unscoped);
332
+ (0, index_1.registerContractResolver)(contractId, 1, scopedA, {
333
+ providerPackageId: providerA,
334
+ });
335
+ expect((0, index_1.getContractResolver)(contractId, 1, providerA)).toBe(scopedA);
336
+ expect((0, index_1.getContractResolver)(contractId, 1, providerB)).toBe(unscoped);
337
+ expect((0, index_1.getContractResolver)(contractId, 1)).toBe(unscoped);
338
+ });
339
+ it("supports legacy unscoped lookup only when a scoped provider is unambiguous", () => {
340
+ const contractId = (0, utils_1.newid)();
341
+ const providerA = (0, utils_1.newid)();
342
+ const providerB = (0, utils_1.newid)();
343
+ const scopedA = jest.fn(() => ({ contractId, version: 1 }));
344
+ const scopedB = jest.fn(() => ({ contractId, version: 1 }));
345
+ (0, index_1.registerContractResolver)(contractId, 1, scopedA, { providerPackageId: providerA });
346
+ expect((0, index_1.getContractResolver)(contractId, 1)).toBe(scopedA);
347
+ (0, index_1.registerContractResolver)(contractId, 1, scopedB, { providerPackageId: providerB });
348
+ expect((0, index_1.getContractResolver)(contractId, 1)).toBeUndefined();
349
+ expect((0, index_1.getContractResolver)(contractId, 1, providerB)).toBe(scopedB);
350
+ });
351
+ });
@@ -64,3 +64,107 @@ describe("ContractRegistry.selectProvider", () => {
64
64
  expect(empty.selectProvider({ contractId, version: 1 })).toBeUndefined();
65
65
  });
66
66
  });
67
+ describe("ContractRegistry.registerBatch", () => {
68
+ it("registers same-batch alsoImplements targets atomically", () => {
69
+ const registry = new registry_1.ContractRegistry();
70
+ const providerPackageId = (0, utils_1.newid)();
71
+ const targetContractId = (0, utils_1.newid)();
72
+ const implementationContractId = (0, utils_1.newid)();
73
+ const result = registry.registerBatch(providerPackageId, [
74
+ { definition: makeDefinition(targetContractId, 1, "Target") },
75
+ {
76
+ definition: makeDefinition(implementationContractId, 2, "Implementation"),
77
+ alsoImplements: [{ contractId: targetContractId, version: 1 }],
78
+ },
79
+ ]);
80
+ expect(result.valid).toBe(true);
81
+ expect(registry.getProviderPackageId(targetContractId, 1)).toBe(providerPackageId);
82
+ expect(registry.getProviderPackageId(implementationContractId, 2)).toBe(providerPackageId);
83
+ });
84
+ it("registers alsoImplements as a routable provider alias", () => {
85
+ const registry = new registry_1.ContractRegistry();
86
+ const originalProvider = (0, utils_1.newid)();
87
+ const compatibleProvider = (0, utils_1.newid)();
88
+ const contractId = (0, utils_1.newid)();
89
+ registry.register(originalProvider, makeDefinition(contractId, 1, "Version 1"));
90
+ const result = registry.registerBatch(compatibleProvider, [
91
+ {
92
+ definition: makeDefinition(contractId, 2, "Version 2"),
93
+ alsoImplements: [{ contractId, version: 1 }],
94
+ },
95
+ ]);
96
+ expect(result.valid).toBe(true);
97
+ expect(registry.getRegisteredProviderIds(contractId, 1)).toEqual([
98
+ originalProvider,
99
+ compatibleProvider,
100
+ ]);
101
+ expect(registry.getProviderImplementation(contractId, 1, compatibleProvider)).toEqual({
102
+ contractId,
103
+ version: 2,
104
+ });
105
+ });
106
+ it("leaves registry state unchanged when any batch entry fails", () => {
107
+ const registry = new registry_1.ContractRegistry();
108
+ const existingProvider = (0, utils_1.newid)();
109
+ const rejectedProvider = (0, utils_1.newid)();
110
+ const existingContractId = (0, utils_1.newid)();
111
+ const newContractId = (0, utils_1.newid)();
112
+ const frozen = { ...makeDefinition(existingContractId, 1, "Frozen"), devTag: undefined };
113
+ expect(registry.register(existingProvider, frozen).valid).toBe(true);
114
+ const result = registry.registerBatch(rejectedProvider, [
115
+ { definition: makeDefinition(newContractId, 1, "Would Be Partial") },
116
+ { definition: makeDefinition(existingContractId, 1, "Frozen Changed") },
117
+ ]);
118
+ expect(result.valid).toBe(false);
119
+ expect(registry.getDefinition(newContractId, 1)).toBeUndefined();
120
+ expect(registry.getRegisteredProviderIds(newContractId, 1)).toEqual([]);
121
+ expect(registry.getProviderPackageId(existingContractId, 1)).toBe(existingProvider);
122
+ expect(registry.getRegisteredProviderIds(existingContractId, 1)).toEqual([existingProvider]);
123
+ });
124
+ it("preserves the existing active provider when another package registers a batch", () => {
125
+ const registry = new registry_1.ContractRegistry();
126
+ const contractId = (0, utils_1.newid)();
127
+ const activeProvider = (0, utils_1.newid)();
128
+ const additionalProvider = (0, utils_1.newid)();
129
+ registry.register(activeProvider, makeDefinition(contractId, 1, "Active"));
130
+ expect(registry.registerBatch(additionalProvider, [
131
+ { definition: makeDefinition(contractId, 1, "Additional") },
132
+ ]).valid).toBe(true);
133
+ expect(registry.getProviderPackageId(contractId, 1)).toBe(activeProvider);
134
+ });
135
+ });
136
+ describe("ContractRegistry.replaceBatch", () => {
137
+ it("replaces the provider's complete contract set and preserves its active selections", () => {
138
+ const registry = new registry_1.ContractRegistry();
139
+ const providerPackageId = (0, utils_1.newid)();
140
+ const retainedContractId = (0, utils_1.newid)();
141
+ const removedContractId = (0, utils_1.newid)();
142
+ const addedContractId = (0, utils_1.newid)();
143
+ registry.registerBatch(providerPackageId, [
144
+ { definition: makeDefinition(retainedContractId, 1, "Retained") },
145
+ { definition: makeDefinition(removedContractId, 1, "Removed") },
146
+ ]);
147
+ const result = registry.replaceBatch(providerPackageId, [
148
+ { definition: makeDefinition(retainedContractId, 1, "Retained replacement") },
149
+ { definition: makeDefinition(addedContractId, 1, "Added") },
150
+ ]);
151
+ expect(result.valid).toBe(true);
152
+ expect(registry.getProviderPackageId(retainedContractId, 1)).toBe(providerPackageId);
153
+ expect(registry.getRegisteredProviderIds(removedContractId, 1)).toEqual([]);
154
+ expect(registry.getProviderPackageId(addedContractId, 1)).toBe(providerPackageId);
155
+ });
156
+ it("leaves the previous provider set untouched when replacement validation fails", () => {
157
+ const registry = new registry_1.ContractRegistry();
158
+ const providerPackageId = (0, utils_1.newid)();
159
+ const existingContractId = (0, utils_1.newid)();
160
+ const rejectedContractId = (0, utils_1.newid)();
161
+ registry.register(providerPackageId, makeDefinition(existingContractId, 1, "Existing"));
162
+ const result = registry.replaceBatch(providerPackageId, [
163
+ { definition: makeDefinition(rejectedContractId, 1, "Rejected") },
164
+ { definition: makeDefinition(rejectedContractId, 1, "Duplicate") },
165
+ ]);
166
+ expect(result.valid).toBe(false);
167
+ expect(registry.getProviderPackageId(existingContractId, 1)).toBe(providerPackageId);
168
+ expect(registry.getRegisteredProviderIds(rejectedContractId, 1)).toEqual([]);
169
+ });
170
+ });