@frockbot/plugin-settings 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/user.ts CHANGED
@@ -2,18 +2,18 @@ import {
2
2
  configurationCommandFingerprintV1,
3
3
  ConfigurationConflictError,
4
4
  ConfigurationDecodeError,
5
+ decodePackageSettingIdsV1,
5
6
  decodePackageSettingsPatchV1,
6
7
  MAX_PACKAGE_SETTINGS_V1,
7
- decodeConnectionDependencyRequirementV1,
8
8
  decodeOperationReceiptV1,
9
9
  decodeUserConfigurationExecuteRpcV1,
10
10
  decodeUserConfigurationReadRpcV1,
11
11
  decodeUserSettingsViewV1,
12
12
  MAX_USER_CONNECTIONS_V1,
13
13
  USER_PROFILE_PLACEHOLDER_NAME_V1,
14
- type ConnectionDependencyRequirementV1,
15
14
  type ConnectionView,
16
15
  type JsonValue,
16
+ type PackageSettingValueV1,
17
17
  type OperationReceiptV1,
18
18
  type PackageInstallationView,
19
19
  type UserConfigurationCommandV1,
@@ -41,16 +41,6 @@ const DEFAULT_PACKAGES_BOOTSTRAP_KEY = "user-default-packages-bootstrap:v1";
41
41
  const CATALOG_PIN_KEY = "user-catalog-pin";
42
42
  const IDENTITY_KEY = "user-id";
43
43
  const RECEIPT_PREFIX = "configuration-receipt:";
44
- const MAX_CONNECTION_DEPENDENCIES = 256;
45
-
46
- type ConnectionDependency = {
47
- botId: string;
48
- generation: string;
49
- packageId: string;
50
- capabilityId: string;
51
- claimOrder: number;
52
- status: "pending" | "acknowledged";
53
- };
54
44
 
55
45
  interface StoredConfigurationReceipt {
56
46
  commandFingerprint: string;
@@ -137,6 +127,10 @@ export interface AvailableUserPackage {
137
127
  * from their first configuration read.
138
128
  */
139
129
  installByDefault?: boolean;
130
+ /** The seeded installation state. Omission preserves the enabled default. */
131
+ defaultEnablement?: "enabled" | "disabled";
132
+ /** The Package manifest's Package-id-to-version-range dependency record. */
133
+ dependencies?: Readonly<Record<string, string>>;
140
134
  /**
141
135
  * `configuration.settings` from this version's manifest. Absent is the same
142
136
  * as empty and means the Package offers no User-level setting, so every
@@ -195,61 +189,6 @@ function requireMatchingConfigurationReceipt(
195
189
  return stored.receipt;
196
190
  }
197
191
 
198
- function connectionDependencies(
199
- connection: ConnectionView,
200
- ): ConnectionDependency[] {
201
- const value = connection.safeMetadata.dependentAssignments;
202
- if (!Array.isArray(value)) return [];
203
- return value.flatMap((candidate) => {
204
- if (
205
- !candidate ||
206
- typeof candidate !== "object" ||
207
- Array.isArray(candidate)
208
- ) {
209
- return [];
210
- }
211
- const dependency = candidate as Record<string, unknown>;
212
- if (
213
- typeof dependency.botId !== "string" ||
214
- typeof dependency.generation !== "string" ||
215
- typeof dependency.packageId !== "string" ||
216
- typeof dependency.capabilityId !== "string" ||
217
- (dependency.claimOrder !== undefined &&
218
- (!Number.isSafeInteger(dependency.claimOrder) ||
219
- (dependency.claimOrder as number) < 0)) ||
220
- (dependency.status !== "pending" && dependency.status !== "acknowledged")
221
- ) {
222
- return [];
223
- }
224
- return [
225
- {
226
- botId: dependency.botId,
227
- generation: dependency.generation,
228
- packageId: dependency.packageId,
229
- capabilityId: dependency.capabilityId,
230
- claimOrder:
231
- dependency.claimOrder === undefined
232
- ? 0
233
- : (dependency.claimOrder as number),
234
- status: dependency.status,
235
- } satisfies ConnectionDependency,
236
- ];
237
- });
238
- }
239
-
240
- function withConnectionDependencies(
241
- connection: ConnectionView,
242
- dependencies: ConnectionDependency[],
243
- ): ConnectionView {
244
- return {
245
- ...connection,
246
- safeMetadata: {
247
- ...connection.safeMetadata,
248
- dependentAssignments: dependencies,
249
- },
250
- };
251
- }
252
-
253
192
  /**
254
193
  * An install may only name the generation this User is pinned to. Refusing
255
194
  * anything else is what makes "Composition consumes immutable,
@@ -292,13 +231,17 @@ function withCatalogPin(
292
231
  * client already reads needs no second source.
293
232
  */
294
233
  function mergePackageSettingValues(
295
- current: Record<string, JsonValue> | undefined,
296
- patch: Record<string, string | number | boolean>,
297
- ): Record<string, JsonValue> {
298
- const merged: Record<string, JsonValue> = { ...(current ?? {}) };
234
+ current: Record<string, JsonValue | PackageSettingValueV1> | undefined,
235
+ patch: Record<string, PackageSettingValueV1>,
236
+ unset: readonly string[],
237
+ ): Record<string, JsonValue | PackageSettingValueV1> {
238
+ const merged: Record<string, JsonValue | PackageSettingValueV1> = {
239
+ ...(current ?? {}),
240
+ };
299
241
  for (const [settingId, value] of Object.entries(patch)) {
300
242
  merged[settingId] = value;
301
243
  }
244
+ for (const settingId of unset) delete merged[settingId];
302
245
  if (Object.keys(merged).length > MAX_PACKAGE_SETTINGS_V1) {
303
246
  throw new ConfigurationDecodeError("Package settings are too many");
304
247
  }
@@ -317,12 +260,11 @@ function applyUserCommand(
317
260
  switch (command.type) {
318
261
  case "user/update-profile":
319
262
  return { ...current, revision, profile: command.profile };
320
- case "user/set-new-bot-model":
263
+ case "user/set-platform-model":
321
264
  return {
322
265
  ...current,
323
266
  revision,
324
- newBotModelTemplate: command.model,
325
- newBotModelTemplateSource: command.source,
267
+ platformModel: command.model,
326
268
  };
327
269
  case "user/install-package": {
328
270
  const existing = current.packages.find(
@@ -346,7 +288,12 @@ function applyUserCommand(
346
288
  {
347
289
  packageId: command.packageId,
348
290
  version: command.version,
349
- state: existing?.state === "failed" ? "failed" : "installed",
291
+ state:
292
+ existing?.state === "failed"
293
+ ? "failed"
294
+ : command.enabled === false
295
+ ? "disabled"
296
+ : "installed",
350
297
  failure: existing?.failure,
351
298
  // A Catalog install records where it came from; the compiled-in
352
299
  // path records nothing new, so an old row keeps its exact shape.
@@ -363,10 +310,8 @@ function applyUserCommand(
363
310
  };
364
311
  }
365
312
  case "user/uninstall-package": {
366
- // Removing the row is the whole effect. Assignments that depend on it
367
- // are not touched: `capabilityAssignmentFailureV1` resolves them as
368
- // unavailable tombstones the User can repair (ADR 0003), and
369
- // Connections are the User's own and outlive any Package.
313
+ // Removing the row is the whole effect. Connections are the User's own
314
+ // and outlive any Package (ADR 0019).
370
315
  if (
371
316
  !current.packages.some((pkg) => pkg.packageId === command.packageId)
372
317
  ) {
@@ -391,20 +336,33 @@ function applyUserCommand(
391
336
  }
392
337
  // Validated against the manifest of the version this User has, not the
393
338
  // one the client happened to be looking at.
394
- const patch = decodePackageSettingsPatchV1(
395
- settingDefinitions(installed.packageId, installed.version),
396
- command.values,
339
+ const definitions = settingDefinitions(
340
+ installed.packageId,
341
+ installed.version,
397
342
  );
343
+ const patch = command.values
344
+ ? decodePackageSettingsPatchV1(definitions, command.values)
345
+ : {};
346
+ const unset = command.unset
347
+ ? decodePackageSettingIdsV1(definitions, command.unset)
348
+ : [];
398
349
  return {
399
350
  ...current,
400
351
  revision,
401
352
  packages: current.packages.map((pkg) =>
402
- pkg.packageId === command.packageId
403
- ? {
404
- ...pkg,
405
- values: mergePackageSettingValues(pkg.values, patch),
406
- }
407
- : pkg,
353
+ pkg.packageId !== command.packageId
354
+ ? pkg
355
+ : (() => {
356
+ const values = mergePackageSettingValues(
357
+ pkg.values,
358
+ patch,
359
+ unset,
360
+ );
361
+ const { values: _storedValues, ...withoutValues } = pkg;
362
+ return Object.keys(values).length > 0
363
+ ? { ...withoutValues, values }
364
+ : withoutValues;
365
+ })(),
408
366
  ),
409
367
  };
410
368
  }
@@ -435,6 +393,12 @@ function applyUserCommand(
435
393
  export class UserSettingsBackendContribution {
436
394
  private readonly availablePackages: ReadonlySet<string>;
437
395
 
396
+ /** Declared Package dependencies, by Package id and version. */
397
+ private readonly packageDependencies: ReadonlyMap<
398
+ string,
399
+ Readonly<Record<string, string>>
400
+ >;
401
+
438
402
  /** The immutable first-party installation rows written on first read. */
439
403
  private readonly defaultPackages: readonly PackageInstallationView[];
440
404
 
@@ -457,6 +421,12 @@ export class UserSettingsBackendContribution {
457
421
  ({ packageId, version }) => `${packageId}\u0000${version}`,
458
422
  ),
459
423
  );
424
+ this.packageDependencies = new Map(
425
+ host.availablePackages.map((pkg) => [
426
+ `${pkg.packageId}\u0000${pkg.version}`,
427
+ pkg.dependencies ?? {},
428
+ ]),
429
+ );
460
430
  this.packageSettingDefinitions = new Map(
461
431
  host.availablePackages.map((pkg) => [
462
432
  `${pkg.packageId}\u0000${pkg.version}`,
@@ -469,7 +439,10 @@ export class UserSettingsBackendContribution {
469
439
  {
470
440
  packageId: pkg.packageId,
471
441
  version: pkg.version,
472
- state: "installed" as const,
442
+ state:
443
+ pkg.defaultEnablement === "disabled"
444
+ ? ("disabled" as const)
445
+ : ("installed" as const),
473
446
  provenance: "first-party" as const,
474
447
  },
475
448
  ]
@@ -549,6 +522,26 @@ export class UserSettingsBackendContribution {
549
522
  );
550
523
  }
551
524
 
525
+ private packageDependencyFailure(
526
+ packageId: string,
527
+ version: string,
528
+ settings: UserSettingsViewV1,
529
+ ): string | undefined {
530
+ const dependencies = this.packageDependencies.get(
531
+ `${packageId}\u0000${version}`,
532
+ );
533
+ if (!dependencies) return undefined;
534
+ for (const dependencyId of Object.keys(dependencies).sort()) {
535
+ const available = settings.packages.some(
536
+ (pkg) => pkg.packageId === dependencyId && pkg.state === "installed",
537
+ );
538
+ if (!available) {
539
+ return `Package "${packageId}" requires Package "${dependencyId}" to be installed and enabled; enable "${dependencyId}" first`;
540
+ }
541
+ }
542
+ return undefined;
543
+ }
544
+
552
545
  async readConfiguration(input: unknown): Promise<UserSettingsViewV1> {
553
546
  const request = decodeUserConfigurationReadRpcV1(input);
554
547
  for (const bootstrap of this.readBootstraps.values()) {
@@ -755,6 +748,36 @@ export class UserSettingsBackendContribution {
755
748
  if (command.expectedRevision !== current.revision) {
756
749
  throw new ConfigurationConflictError(current.revision);
757
750
  }
751
+ let dependencyFailure: string | undefined;
752
+ if (command.type === "user/install-package" && command.enabled !== false) {
753
+ dependencyFailure = this.packageDependencyFailure(
754
+ command.packageId,
755
+ command.version,
756
+ current,
757
+ );
758
+ } else if (command.type === "user/set-package-enabled" && command.enabled) {
759
+ const installed = current.packages.find(
760
+ (pkg) => pkg.packageId === command.packageId,
761
+ );
762
+ if (installed) {
763
+ dependencyFailure = this.packageDependencyFailure(
764
+ installed.packageId,
765
+ installed.version,
766
+ current,
767
+ );
768
+ }
769
+ }
770
+ if (dependencyFailure) {
771
+ const receipt: OperationReceiptV1 = {
772
+ schemaVersion: 1,
773
+ commandId: command.commandId,
774
+ revision: current.revision,
775
+ status: "rejected",
776
+ failure: dependencyFailure,
777
+ };
778
+ await storage.put(receiptKey, { commandFingerprint, receipt });
779
+ return receipt;
780
+ }
758
781
  const next = applyUserCommand(current, command, (packageId, version) =>
759
782
  this.settingDefinitions(packageId, version),
760
783
  );
@@ -937,261 +960,6 @@ export class UserSettingsBackendContribution {
937
960
  );
938
961
  }
939
962
 
940
- /**
941
- * The durable state of one Bot's dependency on one Connection. `absent` is
942
- * the answer for a Connection this object does not hold, so a reconciling
943
- * saga can distinguish "never claimed" from "claimed and pending".
944
- */
945
- async readConnectionDependency(
946
- userId: string,
947
- connectionId: string,
948
- botId: string,
949
- generation: string,
950
- ): Promise<"absent" | "pending" | "acknowledged"> {
951
- const connection = await this.getConnection(userId, connectionId);
952
- if (!connection) return "absent";
953
- const dependency = connectionDependencies(connection).find(
954
- (candidate) =>
955
- candidate.botId === botId && candidate.generation === generation,
956
- );
957
- return dependency?.status ?? "absent";
958
- }
959
-
960
- async claimConnectionDependency(
961
- userId: string,
962
- connectionId: string,
963
- botId: string,
964
- generation: string,
965
- requirement: ConnectionDependencyRequirementV1,
966
- storage?: UserSettingsTransaction,
967
- ): Promise<boolean> {
968
- const decoded = decodeConnectionDependencyRequirementV1(requirement);
969
- return this.transitionConnectionDependency(
970
- userId,
971
- connectionId,
972
- (current, settings) => {
973
- const installation = settings.packages.find(
974
- (pkg) =>
975
- pkg.packageId === decoded.packageId &&
976
- pkg.version === decoded.packageVersion &&
977
- pkg.state === "installed",
978
- );
979
- if (
980
- !installation ||
981
- current.state !== "ready" ||
982
- current.packageId !== decoded.packageId ||
983
- !decoded.connectionTypeIds.includes(current.connectionTypeId)
984
- ) {
985
- return undefined;
986
- }
987
- const existing = connectionDependencies(current);
988
- const replay = existing.find(
989
- (dependency) =>
990
- dependency.botId === botId && dependency.generation === generation,
991
- );
992
- if (replay) {
993
- return replay.packageId === decoded.packageId &&
994
- replay.capabilityId === decoded.capabilityId
995
- ? current
996
- : undefined;
997
- }
998
- if (existing.length >= MAX_CONNECTION_DEPENDENCIES) return undefined;
999
- return withConnectionDependencies(current, [
1000
- ...existing,
1001
- {
1002
- botId,
1003
- generation,
1004
- packageId: decoded.packageId,
1005
- capabilityId: decoded.capabilityId,
1006
- claimOrder: settings.revision + 1,
1007
- status: "pending",
1008
- },
1009
- ]);
1010
- },
1011
- storage,
1012
- );
1013
- }
1014
-
1015
- async acknowledgeConnectionDependency(
1016
- userId: string,
1017
- connectionId: string,
1018
- botId: string,
1019
- generation: string,
1020
- ): Promise<boolean> {
1021
- return this.host.storage.transaction(async (storage) => {
1022
- await this.assertIdentity(userId, storage);
1023
- const current = await this.readSnapshot(storage);
1024
- const target = current.connections.find(
1025
- (connection) => connection.connectionId === connectionId,
1026
- );
1027
- if (
1028
- !target ||
1029
- target.state === "revoking" ||
1030
- target.state === "revoked"
1031
- ) {
1032
- return false;
1033
- }
1034
- const matched = connectionDependencies(target).find(
1035
- (dependency) =>
1036
- dependency.botId === botId && dependency.generation === generation,
1037
- );
1038
- if (!matched) return false;
1039
- const latestClaimOrder = Math.max(
1040
- ...current.connections.flatMap((connection) =>
1041
- connectionDependencies(connection).flatMap((dependency) =>
1042
- dependency.botId === botId &&
1043
- dependency.packageId === matched.packageId &&
1044
- dependency.capabilityId === matched.capabilityId
1045
- ? [dependency.claimOrder]
1046
- : [],
1047
- ),
1048
- ),
1049
- );
1050
- if (matched.claimOrder < latestClaimOrder) return false;
1051
- if (
1052
- matched.status === "acknowledged" &&
1053
- !current.connections.some((connection) =>
1054
- connectionDependencies(connection).some(
1055
- (dependency) =>
1056
- dependency.botId === botId &&
1057
- dependency.packageId === matched.packageId &&
1058
- dependency.capabilityId === matched.capabilityId &&
1059
- (connection.connectionId !== connectionId ||
1060
- dependency.generation !== generation),
1061
- ),
1062
- )
1063
- ) {
1064
- return true;
1065
- }
1066
- const connections = current.connections.map((connection) => {
1067
- const dependencies = connectionDependencies(connection);
1068
- const nextDependencies = dependencies.flatMap((dependency) => {
1069
- const sameAuthority =
1070
- dependency.botId === botId &&
1071
- dependency.packageId === matched.packageId &&
1072
- dependency.capabilityId === matched.capabilityId;
1073
- if (!sameAuthority) return [dependency];
1074
- if (
1075
- connection.connectionId === connectionId &&
1076
- dependency.generation === generation
1077
- ) {
1078
- return [{ ...dependency, status: "acknowledged" as const }];
1079
- }
1080
- return [];
1081
- });
1082
- return nextDependencies.length === dependencies.length &&
1083
- nextDependencies.every(
1084
- (dependency, index) => dependency === dependencies[index],
1085
- )
1086
- ? connection
1087
- : withConnectionDependencies(connection, nextDependencies);
1088
- });
1089
- await storage.put(STATE_KEY, {
1090
- ...current,
1091
- revision: current.revision + 1,
1092
- connections,
1093
- } satisfies UserSettingsViewV1);
1094
- return true;
1095
- });
1096
- }
1097
-
1098
- async releaseConnectionDependency(
1099
- userId: string,
1100
- connectionId: string,
1101
- botId: string,
1102
- generation: string,
1103
- ): Promise<boolean> {
1104
- return this.host.storage.transaction(async (storage) => {
1105
- await this.assertIdentity(userId, storage);
1106
- const current = await this.readSnapshot(storage);
1107
- const target = current.connections.find(
1108
- (connection) => connection.connectionId === connectionId,
1109
- );
1110
- if (!target) return true;
1111
- const existing = connectionDependencies(target);
1112
- const matching = existing.filter(
1113
- (dependency) =>
1114
- dependency.botId === botId && dependency.generation === generation,
1115
- );
1116
- if (matching.length === 0) return true;
1117
- if (matching.some((dependency) => dependency.status !== "acknowledged")) {
1118
- return false;
1119
- }
1120
- const remaining = existing.filter(
1121
- (dependency) =>
1122
- dependency.botId !== botId || dependency.generation !== generation,
1123
- );
1124
- const connections = current.connections.map((connection) =>
1125
- connection.connectionId === connectionId
1126
- ? withConnectionDependencies(connection, remaining)
1127
- : connection,
1128
- );
1129
- await storage.put(STATE_KEY, {
1130
- ...current,
1131
- revision: current.revision + 1,
1132
- connections,
1133
- } satisfies UserSettingsViewV1);
1134
- return true;
1135
- });
1136
- }
1137
-
1138
- async compensateConnectionDependency(
1139
- userId: string,
1140
- connectionId: string,
1141
- botId: string,
1142
- generation: string,
1143
- ): Promise<boolean> {
1144
- return this.transitionConnectionDependency(
1145
- userId,
1146
- connectionId,
1147
- (current) => {
1148
- const existing = connectionDependencies(current);
1149
- const remaining = existing.filter(
1150
- (dependency) =>
1151
- dependency.botId !== botId ||
1152
- dependency.generation !== generation ||
1153
- dependency.status !== "pending",
1154
- );
1155
- return remaining.length === existing.length
1156
- ? undefined
1157
- : withConnectionDependencies(current, remaining);
1158
- },
1159
- );
1160
- }
1161
-
1162
- private async transitionConnectionDependency(
1163
- userId: string,
1164
- connectionId: string,
1165
- transition: (
1166
- connection: ConnectionView,
1167
- settings: UserSettingsViewV1,
1168
- ) => ConnectionView | undefined,
1169
- transaction?: UserSettingsTransaction,
1170
- ): Promise<boolean> {
1171
- const apply = async (storage: UserSettingsTransaction) => {
1172
- await this.assertIdentity(userId, storage);
1173
- const current = await this.readSnapshot(storage);
1174
- const connection = current.connections.find(
1175
- (candidate) => candidate.connectionId === connectionId,
1176
- );
1177
- if (!connection) return false;
1178
- const nextConnection = transition(connection, current);
1179
- if (!nextConnection) return false;
1180
- if (nextConnection === connection) return true;
1181
- await storage.put(STATE_KEY, {
1182
- ...current,
1183
- revision: current.revision + 1,
1184
- connections: current.connections.map((candidate) =>
1185
- candidate.connectionId === connectionId ? nextConnection : candidate,
1186
- ),
1187
- } satisfies UserSettingsViewV1);
1188
- return true;
1189
- };
1190
- return transaction
1191
- ? apply(transaction)
1192
- : this.host.storage.transaction(apply);
1193
- }
1194
-
1195
963
  private async assertIdentity(
1196
964
  userId: string,
1197
965
  storage: UserSettingsTransaction = this.host.storage,
@@ -1,22 +0,0 @@
1
- import type {
2
- BotSettingsViewV1,
3
- CapabilityAssignmentOperationViewV1,
4
- } from "@frockbot/configuration-core";
5
-
6
- /** Projects every durable Assignment operation without requiring catalog state. */
7
- export function projectAssignmentOperations(
8
- settings: Pick<BotSettingsViewV1, "assignmentOperations"> | undefined,
9
- ): CapabilityAssignmentOperationViewV1[] {
10
- return (settings?.assignmentOperations ?? []).map((operation) =>
11
- structuredClone(operation),
12
- );
13
- }
14
-
15
- export function assignmentHasPendingOperation(
16
- operations: readonly CapabilityAssignmentOperationViewV1[],
17
- assignmentId: string,
18
- ): boolean {
19
- return operations.some(
20
- (operation) => operation.assignmentId === assignmentId,
21
- );
22
- }