@peers-app/peers-sdk 0.27.1 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -665,7 +665,6 @@ describe("bare deterministic row repair", () => {
665
665
  ["invalid empty identity", { catalogIdentity: "" }],
666
666
  ["different name", { name: "write" }],
667
667
  ["different name case", { name: "Read" }],
668
- ["matching stored target", { contractId, contractVersion: 1, toolName: "read" }],
669
668
  ["conflicting stored target", { contractId, contractVersion: 1, toolName: "write" }],
670
669
  ["partial contract ID", { contractId }],
671
670
  ["partial version", { contractVersion: 1 }],
@@ -673,9 +672,7 @@ describe("bare deterministic row repair", () => {
673
672
  ])("rejects %s even for an explicit catalog patch", (_name, fields) => {
674
673
  expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, ...fields }, restored, toolId, true)).toThrow(/Quarantined/);
675
674
  // Exercise the cannot-be-claimed branch without a quarantine early-out too.
676
- // A matching established target is already a valid mapping in this case.
677
- if (_name !== "matching stored target")
678
- expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, ...fields, catalogState: undefined }, restored, toolId, true)).toThrow();
675
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, ...fields, catalogState: undefined }, restored, toolId, true)).toThrow();
679
676
  });
680
677
  test.each([
681
678
  ["missing target", { contractId: undefined, contractVersion: undefined, toolName: undefined }],
@@ -690,9 +687,9 @@ describe("bare deterministic row repair", () => {
690
687
  ])("rejects a candidate with %s", (_name, fields) => {
691
688
  expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, { ...restored, ...fields }, toolId, true)).toThrow();
692
689
  });
693
- test("ordinary saves and replay cannot clear quarantine, even with the canonical target", () => {
690
+ test("ordinary saves and replay clear a missing-target quarantine only with the canonical target", () => {
691
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, restored)).not.toThrow();
694
692
  for (const candidate of [
695
- restored,
696
693
  { ...bare, catalogState: "resolved" },
697
694
  { ...bare, catalogState: undefined, usageDescription: "metadata" },
698
695
  ])
@@ -931,3 +928,272 @@ test("ready upgrades unresolved host aliases, keeps policy, refreshes descriptio
931
928
  expect(restarted.rows.get(old.toolId)?.catalogState).toBe("resolved");
932
929
  await restarted.catalog.dispose();
933
930
  });
931
+ describe("missing-target quarantine resolution", () => {
932
+ const target = { contractId, version: 1, toolName: "read" };
933
+ const toolId = (0, tool_catalog_identity_1.deriveToolCatalogId)(target);
934
+ const missingTarget = `Error: Missing tool catalog target: ${toolId}`;
935
+ // The shape found on Mark's Family Hub (2026-10-26): a bare deterministic row that reconcile()
936
+ // quarantined, while peers hold the published root plus the synced quarantine patches.
937
+ const bare = {
938
+ toolId,
939
+ name: "read",
940
+ usageDescription: "stale",
941
+ code: "",
942
+ inputSchema: { type: tools_1.IOSchemaType.none, fields: [] },
943
+ outputSchema: { type: tools_1.IOSchemaType.none, fields: [] },
944
+ catalogState: "quarantined",
945
+ catalogReason: missingTarget,
946
+ };
947
+ const published = {
948
+ ...bare,
949
+ contractId,
950
+ contractVersion: 1,
951
+ toolName: "read",
952
+ catalogIdentity: "deterministic",
953
+ catalogState: "resolved",
954
+ catalogReason: undefined,
955
+ };
956
+ const replayed = {
957
+ ...published,
958
+ catalogState: "quarantined",
959
+ catalogReason: missingTarget,
960
+ };
961
+ test("sync replay may give a bare row its own target, still quarantined, and reconcile resolves it", async () => {
962
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, replayed)).not.toThrow();
963
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, published)).not.toThrow();
964
+ const f = fixture(new Map([[toolId, replayed]]));
965
+ try {
966
+ await f.catalog.ready();
967
+ expect(f.rows.get(toolId)).toMatchObject({
968
+ contractId,
969
+ contractVersion: 1,
970
+ toolName: "read",
971
+ catalogIdentity: "deterministic",
972
+ catalogState: "resolved",
973
+ });
974
+ expect(f.rows.get(toolId)?.catalogReason).toBeUndefined();
975
+ }
976
+ finally {
977
+ await f.catalog.dispose();
978
+ }
979
+ });
980
+ test("publication resolves a row left half-restored with its target", async () => {
981
+ const f = fixture();
982
+ try {
983
+ await f.catalog.ready();
984
+ f.rows.set(toolId, replayed);
985
+ await f.publish();
986
+ expect(f.rows.get(toolId)?.catalogState).toBe("resolved");
987
+ expect(f.registry.resolve(contractId, 1)?.name).toBe("Test");
988
+ }
989
+ finally {
990
+ await f.catalog.dispose();
991
+ }
992
+ });
993
+ test.each([
994
+ ["another quarantine reason", { catalogReason: "Error: Tool catalog identity cannot change" }],
995
+ ["no quarantine reason", { catalogReason: undefined }],
996
+ ["a reason mentioning missing targets", { catalogReason: `Conflict; ${missingTarget}` }],
997
+ ["a reason with a suffix", { catalogReason: `${missingTarget}; identity collision` }],
998
+ [
999
+ "another row's missing target",
1000
+ { catalogReason: `Error: Missing tool catalog target: ${(0, utils_1.newid)()}` },
1001
+ ],
1002
+ ["executable source", { code: "existing" }],
1003
+ ["a different name", { name: "write" }],
1004
+ ["another identity kind", { catalogIdentity: "legacy-alias" }],
1005
+ ["a partial target", { contractVersion: undefined }],
1006
+ ["a target its ID doesn't derive from", { contractVersion: 2 }],
1007
+ ])("a quarantined row with %s stays quarantined", async (_name, fields) => {
1008
+ const stuck = { ...replayed, ...fields };
1009
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(stuck, published)).toThrow(/Quarantined/);
1010
+ const f = fixture(new Map([[toolId, stuck]]));
1011
+ try {
1012
+ await f.catalog.ready();
1013
+ expect(f.rows.get(toolId)?.catalogState).toBe("quarantined");
1014
+ }
1015
+ finally {
1016
+ await f.catalog.dispose();
1017
+ }
1018
+ });
1019
+ test.each([
1020
+ ["another quarantine reason", { catalogReason: "Error: Tool catalog identity cannot change" }],
1021
+ ["no quarantine reason", { catalogReason: undefined }],
1022
+ ["a reason mentioning missing targets", { catalogReason: `Conflict; ${missingTarget}` }],
1023
+ [
1024
+ "another row's missing target",
1025
+ { catalogReason: `Error: Missing tool catalog target: ${(0, utils_1.newid)()}` },
1026
+ ],
1027
+ ["unset identity kind", { catalogIdentity: undefined }],
1028
+ ["a target its ID doesn't derive from", { contractVersion: 2 }],
1029
+ ["executable source", { code: "existing" }],
1030
+ ])("a replayed candidate with %s cannot claim a bare row", (_name, fields) => {
1031
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, { ...replayed, ...fields })).toThrow();
1032
+ });
1033
+ test("reviewed aliases suppress resolution of an aliased member's quarantined rows", async () => {
1034
+ const aliased = { contractId, version: 1, toolName: "new-task" };
1035
+ const rows = [(0, tool_catalog_identity_1.deriveToolCatalogId)(aliased), ...(0, tool_catalog_migrations_1.toolCatalogIds)(aliased)].map((id) => ({
1036
+ ...replayed,
1037
+ toolId: id,
1038
+ name: "new-task",
1039
+ toolName: "new-task",
1040
+ catalogReason: `Error: Missing tool catalog target: ${id}`,
1041
+ }));
1042
+ for (const row of rows)
1043
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(row, {
1044
+ ...row,
1045
+ catalogState: "resolved",
1046
+ catalogReason: undefined,
1047
+ })).toThrow(/Quarantined/);
1048
+ const f = fixture(new Map(rows.map((row) => [row.toolId, row])));
1049
+ try {
1050
+ await f.catalog.ready();
1051
+ for (const row of rows)
1052
+ expect(f.rows.get(row.toolId)).toEqual(row);
1053
+ }
1054
+ finally {
1055
+ await f.catalog.dispose();
1056
+ }
1057
+ });
1058
+ test.each([
1059
+ bare,
1060
+ replayed,
1061
+ ])("sync cannot relabel another quarantine as a missing target (%#)", (row) => {
1062
+ const stuck = { ...row, catalogReason: "Error: Tool catalog identity collision" };
1063
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(stuck, { ...stuck, catalogReason: missingTarget })).toThrow(/Quarantined/);
1064
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(stuck, { ...stuck, usageDescription: "metadata" })).not.toThrow();
1065
+ });
1066
+ test.each([
1067
+ undefined,
1068
+ "deterministic",
1069
+ ])("sync cannot replace a quarantined identity kind with %s to enable repair", (catalogIdentity) => {
1070
+ const stuck = { ...replayed, catalogIdentity: "legacy-alias" };
1071
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(stuck, { ...stuck, catalogIdentity })).toThrow(/Quarantined/);
1072
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(stuck, { ...stuck, usageDescription: "metadata" })).not.toThrow();
1073
+ });
1074
+ test.each([
1075
+ undefined,
1076
+ "unresolved",
1077
+ ])("sync can claim a bare row in state %s", (catalogState) => {
1078
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, catalogState }, published)).not.toThrow();
1079
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, catalogState }, replayed)).not.toThrow();
1080
+ });
1081
+ test("reconciliation patches only identity and state, preserving concurrent policy and history", async () => {
1082
+ const stored = {
1083
+ ...replayed,
1084
+ packageId: (0, utils_1.newid)(),
1085
+ codeRepository: "historical repository",
1086
+ accessLevel: 70,
1087
+ detailedDescription: "saved details",
1088
+ };
1089
+ const f = fixture(new Map([[toolId, stored]]));
1090
+ const update = required(f.table.update.getMockImplementation());
1091
+ f.table.update.mockImplementationOnce(async (patch) => {
1092
+ expect((0, tools_1.isToolCatalogPatch)(patch)).toBe(true);
1093
+ expect(patch).toEqual({
1094
+ toolId,
1095
+ contractId,
1096
+ contractVersion: 1,
1097
+ toolName: "read",
1098
+ catalogIdentity: "deterministic",
1099
+ catalogState: "resolved",
1100
+ catalogReason: undefined,
1101
+ });
1102
+ const current = {
1103
+ ...stored,
1104
+ accessLevel: 90,
1105
+ remoteAccessLevel: 80,
1106
+ authorityPropagation: "preserve",
1107
+ };
1108
+ f.rows.set(toolId, current);
1109
+ (0, tool_catalog_1.validateToolCatalogWrite)(current, { ...current, ...patch }, toolId, true);
1110
+ return update(patch);
1111
+ });
1112
+ try {
1113
+ await f.catalog.ready();
1114
+ expect(f.rows.get(toolId)).toEqual({
1115
+ ...stored,
1116
+ catalogState: "resolved",
1117
+ catalogReason: undefined,
1118
+ accessLevel: 90,
1119
+ remoteAccessLevel: 80,
1120
+ authorityPropagation: "preserve",
1121
+ });
1122
+ }
1123
+ finally {
1124
+ await f.catalog.dispose();
1125
+ }
1126
+ });
1127
+ test("a concurrent conflicting quarantine rejects a stale reconciliation patch", async () => {
1128
+ const f = fixture(new Map([[toolId, replayed]]));
1129
+ const conflict = { ...replayed, catalogReason: "Error: Tool catalog identity collision" };
1130
+ f.table.update.mockImplementationOnce(async (patch) => {
1131
+ f.rows.set(toolId, conflict);
1132
+ (0, tool_catalog_1.validateToolCatalogWrite)(conflict, { ...conflict, ...patch }, toolId, true);
1133
+ throw new Error("Conflict was not rejected");
1134
+ });
1135
+ try {
1136
+ await expect(f.catalog.ready()).rejects.toThrow(/Quarantined/);
1137
+ expect(f.rows.get(toolId)).toEqual(conflict);
1138
+ expect(f.registry.resolve(contractId, 1)).toBeUndefined();
1139
+ }
1140
+ finally {
1141
+ await f.catalog.dispose();
1142
+ }
1143
+ });
1144
+ test("resolved and bare peers converge after replay without recursive repair writes", async () => {
1145
+ const peers = [fixture(new Map([[toolId, published]])), fixture(new Map([[toolId, bare]]))];
1146
+ try {
1147
+ for (const f of peers) {
1148
+ await f.catalog.ready();
1149
+ f.table.update.mockClear();
1150
+ (0, tool_catalog_1.validateToolCatalogWrite)(f.rows.get(toolId), replayed);
1151
+ await f.table.save(replayed);
1152
+ }
1153
+ await new Promise((resolve) => setImmediate(resolve));
1154
+ for (const f of peers) {
1155
+ expect(f.rows.get(toolId)).toEqual(published);
1156
+ expect(f.table.update).toHaveBeenCalledTimes(1);
1157
+ (0, tool_catalog_1.validateToolCatalogWrite)(f.rows.get(toolId), published);
1158
+ await f.table.save(published);
1159
+ }
1160
+ await new Promise((resolve) => setImmediate(resolve));
1161
+ for (const f of peers) {
1162
+ expect(f.table.update).toHaveBeenCalledTimes(1);
1163
+ expect(f.registry.resolve(contractId, 1)).toBeUndefined();
1164
+ }
1165
+ }
1166
+ finally {
1167
+ await Promise.all(peers.map((f) => f.catalog.dispose()));
1168
+ }
1169
+ });
1170
+ test("a hash collision cannot retarget retained missing-target quarantine evidence", () => {
1171
+ const hash = jest.spyOn(nacl, "hash").mockReturnValue(new Uint8Array(64));
1172
+ try {
1173
+ const collisionId = (0, tool_catalog_identity_1.deriveToolCatalogId)(target);
1174
+ expect((0, tool_catalog_identity_1.deriveToolCatalogId)({ ...target, version: 2 })).toBe(collisionId);
1175
+ const stored = {
1176
+ ...replayed,
1177
+ toolId: collisionId,
1178
+ catalogReason: `Error: Missing tool catalog target: ${collisionId}`,
1179
+ };
1180
+ for (const catalogState of ["resolved", "quarantined"])
1181
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(stored, { ...stored, contractVersion: 2, catalogState })).toThrow();
1182
+ }
1183
+ finally {
1184
+ hash.mockRestore();
1185
+ }
1186
+ });
1187
+ test("an established mapping is not re-patched on each publish", async () => {
1188
+ const f = fixture(new Map([[toolId, { ...published, catalogReason: undefined }]]));
1189
+ try {
1190
+ await f.publish();
1191
+ const updates = f.table.update.mock.calls.length;
1192
+ await f.publish(definition(), (0, utils_1.newid)());
1193
+ expect(f.table.update).toHaveBeenCalledTimes(updates);
1194
+ }
1195
+ finally {
1196
+ await f.catalog.dispose();
1197
+ }
1198
+ });
1199
+ });
@@ -28,10 +28,12 @@ export declare function retainToolCatalogFields<T extends Partial<ITool>>(existi
28
28
  * tools move to contracts: they stay ordinary rows that local writers and older
29
29
  * runtimes may delete. A bare deterministic row with an empty executable and the
30
30
  * member's own name may receive that member's canonical target; every other
31
- * untargeted or quarantined identity stays rejected.
31
+ * untargeted or quarantined identity stays rejected, except that a row quarantined only
32
+ * for its missing target takes the one target its deterministic ID derives from, from
33
+ * publication or sync (see `canResolveMissingTarget`).
32
34
  *
33
- * @param catalogPatch - Only explicit local catalog patches may restore a bare
34
- * deterministic row. Ordinary replacements and sync replay must leave it quarantined.
35
+ * @param catalogPatch - Explicit local catalog patches may also restore a bare deterministic
36
+ * row whatever its quarantine reason (`canRestoreBareDeterministicRow`).
35
37
  */
36
38
  export declare function validateToolCatalogWrite(existing: Partial<ITool> | undefined, candidate: Partial<ITool> | undefined, recordId?: string | undefined, catalogPatch?: boolean): void;
37
39
  /**
@@ -28,6 +28,15 @@ function toolCatalogTarget(row) {
28
28
  (0, tool_catalog_identity_1.validateToolCatalogTarget)(target);
29
29
  return target;
30
30
  }
31
+ /** {@link toolCatalogTarget}, or undefined for a partial or invalid target. */
32
+ function toolCatalogTargetOrUndefined(row) {
33
+ try {
34
+ return toolCatalogTarget(row);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
31
40
  /**
32
41
  * Mixed-version sync: runtimes that predate the catalog columns reconstruct and write
33
42
  * full rows without them. When a synced candidate carries none of the catalog-owned
@@ -92,6 +101,51 @@ function canRestoreBareDeterministicRow(existing, candidate) {
92
101
  const ids = (0, tool_catalog_migrations_1.toolCatalogIds)(target);
93
102
  return ids.length === 1 && ids[0] === candidate.toolId && candidate.toolId.startsWith("000tool0");
94
103
  }
104
+ /** The quarantine reason reconcile() records for a deterministic row stored without its target. */
105
+ function isMissingTargetQuarantine(row) {
106
+ return (row.catalogState === "quarantined" &&
107
+ row.catalogReason === `Error: Missing tool catalog target: ${row.toolId}`);
108
+ }
109
+ /**
110
+ * A bare deterministic catalog row (empty executable, the member's own name), or one quarantined
111
+ * only because its target was missing, takes the target its ID derives from, through publication
112
+ * or sync. The candidate must derive this ID and match any retained target. It may still
113
+ * carry that same missing-target quarantine (sync replays
114
+ * the quarantine patches that peers recorded); reconcile() then resolves the row. Any other
115
+ * quarantine reason, executable source, name, identity kind or target stays rejected.
116
+ */
117
+ function canResolveMissingTarget(existing, candidate) {
118
+ if (existing.code !== "" || (candidate.code !== undefined && candidate.code !== ""))
119
+ return false;
120
+ // Only rows that still need resolving: an established mapping has nothing to restore.
121
+ if (existing.catalogState === "resolved")
122
+ return false;
123
+ if (existing.catalogState === "quarantined" && !isMissingTargetQuarantine(existing))
124
+ return false;
125
+ if (candidate.catalogState === "quarantined" && !isMissingTargetQuarantine(candidate))
126
+ return false;
127
+ if (candidate.catalogState !== "resolved" && candidate.catalogState !== "quarantined")
128
+ return false;
129
+ if (candidate.catalogIdentity !== "deterministic")
130
+ return false;
131
+ if (existing.catalogIdentity !== undefined && existing.catalogIdentity !== "deterministic")
132
+ return false;
133
+ let existingTarget;
134
+ let target;
135
+ try {
136
+ existingTarget = toolCatalogTarget(existing);
137
+ target = toolCatalogTarget(candidate);
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ if (!target || !candidate.toolId || existing.name !== target.toolName)
143
+ return false;
144
+ if (existingTarget && (0, tool_catalog_identity_1.toolCatalogTargetKey)(existingTarget) !== (0, tool_catalog_identity_1.toolCatalogTargetKey)(target))
145
+ return false;
146
+ const ids = (0, tool_catalog_migrations_1.toolCatalogIds)(target);
147
+ return ids.length === 1 && ids[0] === candidate.toolId && candidate.toolId.startsWith("000tool0");
148
+ }
95
149
  /**
96
150
  * Shared pre-persistence identity guard for local writes and reconstructed sync rows.
97
151
  * Existing mappings cannot be retargeted, stripped or deleted. Rejected sync evidence
@@ -102,10 +156,12 @@ function canRestoreBareDeterministicRow(existing, candidate) {
102
156
  * tools move to contracts: they stay ordinary rows that local writers and older
103
157
  * runtimes may delete. A bare deterministic row with an empty executable and the
104
158
  * member's own name may receive that member's canonical target; every other
105
- * untargeted or quarantined identity stays rejected.
159
+ * untargeted or quarantined identity stays rejected, except that a row quarantined only
160
+ * for its missing target takes the one target its deterministic ID derives from, from
161
+ * publication or sync (see `canResolveMissingTarget`).
106
162
  *
107
- * @param catalogPatch - Only explicit local catalog patches may restore a bare
108
- * deterministic row. Ordinary replacements and sync replay must leave it quarantined.
163
+ * @param catalogPatch - Explicit local catalog patches may also restore a bare deterministic
164
+ * row whatever its quarantine reason (`canRestoreBareDeterministicRow`).
109
165
  */
110
166
  function validateToolCatalogWrite(existing, candidate, recordId = candidate?.toolId ?? existing?.toolId, catalogPatch = false) {
111
167
  if (!candidate) {
@@ -118,9 +174,10 @@ function validateToolCatalogWrite(existing, candidate, recordId = candidate?.too
118
174
  !(0, tool_catalog_identity_1.isCatalogToolId)(candidate.toolId) ||
119
175
  (existing && existing.toolId !== candidate.toolId))
120
176
  throw new Error("Tool catalog primary key mismatch");
177
+ // Relabeling another quarantine must not enable a later missing-target repair.
121
178
  if (existing?.catalogState === "quarantined" &&
122
- candidate.catalogState !== "quarantined" &&
123
- !(catalogPatch && canRestoreBareDeterministicRow(existing, candidate)))
179
+ !isMissingTargetQuarantine(existing) &&
180
+ isMissingTargetQuarantine(candidate))
124
181
  throw new Error(`Quarantined tool identity: ${candidate.toolId}`);
125
182
  // Quarantine preserves the original evidence byte-for-byte; it cannot retarget it.
126
183
  if (existing &&
@@ -130,6 +187,12 @@ function validateToolCatalogWrite(existing, candidate, recordId = candidate?.too
130
187
  existing.toolName === candidate.toolName &&
131
188
  existing.catalogIdentity === candidate.catalogIdentity)
132
189
  return;
190
+ // Changing quarantined identity evidence also requires an approved repair, even
191
+ // when the candidate stays quarantined and would be resolved by reconciliation.
192
+ if (existing?.catalogState === "quarantined" &&
193
+ !(catalogPatch && canRestoreBareDeterministicRow(existing, candidate)) &&
194
+ !canResolveMissingTarget(existing, candidate))
195
+ throw new Error(`Quarantined tool identity: ${candidate.toolId}`);
133
196
  const target = toolCatalogTarget(candidate);
134
197
  const legacy = (0, tool_catalog_migrations_1.resolveLegacyToolId)(candidate.toolId);
135
198
  const previous = existing && toolCatalogTarget(existing);
@@ -139,7 +202,8 @@ function validateToolCatalogWrite(existing, candidate, recordId = candidate?.too
139
202
  if (existing &&
140
203
  !previous &&
141
204
  !legacy &&
142
- !(catalogPatch && canRestoreBareDeterministicRow(existing, candidate)))
205
+ !(catalogPatch && canRestoreBareDeterministicRow(existing, candidate)) &&
206
+ !canResolveMissingTarget(existing, candidate))
143
207
  throw new Error(`Unresolved tool identity cannot be claimed: ${candidate.toolId}`);
144
208
  if (!(0, tool_catalog_migrations_1.toolCatalogIds)(target).includes(candidate.toolId))
145
209
  throw new Error(`Unknown tool catalog mapping: ${candidate.toolId}`);
@@ -407,7 +471,8 @@ class ToolCatalog {
407
471
  };
408
472
  const stored = existing.get(toolId);
409
473
  if (stored?.catalogState === "quarantined" &&
410
- !canRestoreBareDeterministicRow(stored, row))
474
+ !canRestoreBareDeterministicRow(stored, row) &&
475
+ !canResolveMissingTarget(stored, row))
411
476
  throw new Error(`Quarantined tool identity: ${toolId}`);
412
477
  validateToolCatalogWrite(stored, row, toolId, true);
413
478
  candidates.set(toolId, row);
@@ -420,7 +485,8 @@ class ToolCatalog {
420
485
  const previous = existing.get(row.toolId);
421
486
  // Restore missing identity columns only after validating the entire batch.
422
487
  // This marked patch leaves concurrent policy and historical fields intact.
423
- if (previous && canRestoreBareDeterministicRow(previous, row)) {
488
+ if (previous &&
489
+ (canRestoreBareDeterministicRow(previous, row) || canResolveMissingTarget(previous, row))) {
424
490
  const restored = await table.update((0, tools_1.toolCatalogPatch)({
425
491
  toolId: row.toolId,
426
492
  ...resolvedFields(toolCatalogTarget(row), row.toolId),
@@ -460,9 +526,19 @@ class ToolCatalog {
460
526
  const table = (0, tools_1.Tools)(this.dataContext);
461
527
  for (const row of await table.list()) {
462
528
  let fields;
463
- // Quarantine is sticky: conflicting evidence is never silently rehabilitated.
464
- if (row.catalogState === "quarantined")
529
+ // Quarantine is sticky: conflicting evidence is never silently rehabilitated. The one
530
+ // exception is a row quarantined for its missing target that has since received the
531
+ // target its deterministic ID derives from (from publication or sync).
532
+ if (row.catalogState === "quarantined") {
533
+ const target = toolCatalogTargetOrUndefined(row);
534
+ if (!target)
535
+ continue;
536
+ const resolved = { ...row, ...resolvedFields(target, row.toolId) };
537
+ if (!canResolveMissingTarget(row, resolved))
538
+ continue;
539
+ await table.update((0, tools_1.toolCatalogPatch)({ toolId: row.toolId, ...resolvedFields(target, row.toolId) }));
465
540
  continue;
541
+ }
466
542
  try {
467
543
  validateToolCatalogWrite(undefined, row);
468
544
  const target = (0, tool_catalog_migrations_1.resolveLegacyToolId)(row.toolId) ?? toolCatalogTarget(row);
@@ -131,11 +131,13 @@ interface IPersistentVarOptionsWithoutDefault<T> extends IPersistentVarOptionsBa
131
131
  * A persistent variable that is stored only on this device, in the user's personal db
132
132
  */
133
133
  export declare function deviceVar<T = string>(name: string, opts: Omit<IPersistentVarOptionsWithDefault<T>, "scope" | "dataContext">): PersistentVar<T>;
134
+ /** Create or reuse a {@link deviceVar} observable for this name and context. */
134
135
  export declare function deviceVar<T = string>(name: string, opts?: Omit<IPersistentVarOptionsWithoutDefault<T>, "scope" | "dataContext">): PersistentVar<T | undefined>;
135
136
  /**
136
137
  * A persistent variable that is stored in the user's personal db and synced across all their devices
137
138
  */
138
139
  export declare function userVar<T = string>(name: string, opts: Omit<IPersistentVarOptionsWithDefault<T>, "scope" | "dataContext">): PersistentVar<T>;
140
+ /** Create or reuse a {@link userVar} observable for this name and context. */
139
141
  export declare function userVar<T = string>(name: string, opts?: Omit<IPersistentVarOptionsWithoutDefault<T>, "scope" | "dataContext">): PersistentVar<T | undefined>;
140
142
  /**
141
143
  * A persistent variable that is shared by all users in a group. It is stored in the group's db and synced across all users and devices in the group.
@@ -143,6 +145,7 @@ export declare function userVar<T = string>(name: string, opts?: Omit<IPersisten
143
145
  * If secret=true, it is encrypted with the group's secret key
144
146
  */
145
147
  export declare function groupVar<T = string>(name: string, opts: Omit<IPersistentVarOptionsWithDefault<T>, "scope">): PersistentVar<T>;
148
+ /** Create or reuse a {@link groupVar} observable for this name and context. */
146
149
  export declare function groupVar<T = string>(name: string, opts?: Omit<IPersistentVarOptionsWithoutDefault<T>, "scope">): PersistentVar<T | undefined>;
147
150
  /**
148
151
  * A persistent variable that is stored only on this device, in the user's personal db.
@@ -154,6 +157,7 @@ export declare function groupVar<T = string>(name: string, opts?: Omit<IPersiste
154
157
  * renderer callers that follow the default group share one row.
155
158
  */
156
159
  export declare function groupDeviceVar<T = string>(name: string, opts: Omit<IPersistentVarOptionsWithDefault<T>, "scope">): PersistentVar<T>;
160
+ /** Create or reuse a {@link groupDeviceVar} observable for this name and context. */
157
161
  export declare function groupDeviceVar<T = string>(name: string, opts?: Omit<IPersistentVarOptionsWithoutDefault<T>, "scope">): PersistentVar<T | undefined>;
158
162
  /**
159
163
  * A persistent variable that is stored in the user's personal db and synced across all their devices in the group.
@@ -163,4 +167,5 @@ export declare function groupDeviceVar<T = string>(name: string, opts?: Omit<IPe
163
167
  * only selects the group whose value is addressed.
164
168
  */
165
169
  export declare function groupUserVar<T = string>(name: string, opts: Omit<IPersistentVarOptionsWithDefault<T>, "scope">): PersistentVar<T>;
170
+ /** Create or reuse a {@link groupUserVar} observable for this name and context. */
166
171
  export declare function groupUserVar<T = string>(name: string, opts?: Omit<IPersistentVarOptionsWithoutDefault<T>, "scope">): PersistentVar<T | undefined>;
@@ -278,6 +278,7 @@ function userContextCacheId(userContext) {
278
278
  }
279
279
  return String(id);
280
280
  }
281
+ /** Create or reuse a {@link deviceVar} observable for this name and context. */
281
282
  function deviceVar(name, opts) {
282
283
  const fullOpts = { ...opts, scope: "device" };
283
284
  if (opts?.defaultValue !== undefined) {
@@ -287,6 +288,7 @@ function deviceVar(name, opts) {
287
288
  return persistentVarFactory(name, fullOpts);
288
289
  }
289
290
  }
291
+ /** Create or reuse a {@link userVar} observable for this name and context. */
290
292
  function userVar(name, opts) {
291
293
  const fullOpts = { ...opts, scope: "user" };
292
294
  if (opts?.defaultValue !== undefined) {
@@ -296,6 +298,7 @@ function userVar(name, opts) {
296
298
  return persistentVarFactory(name, fullOpts);
297
299
  }
298
300
  }
301
+ /** Create or reuse a {@link groupVar} observable for this name and context. */
299
302
  function groupVar(name, opts) {
300
303
  const fullOpts = { ...opts, scope: "group" };
301
304
  if (opts?.defaultValue !== undefined) {
@@ -305,6 +308,7 @@ function groupVar(name, opts) {
305
308
  return persistentVarFactory(name, fullOpts);
306
309
  }
307
310
  }
311
+ /** Create or reuse a {@link groupDeviceVar} observable for this name and context. */
308
312
  function groupDeviceVar(name, opts) {
309
313
  const fullOpts = { ...opts, scope: "groupDevice" };
310
314
  if (opts?.defaultValue !== undefined) {
@@ -314,6 +318,7 @@ function groupDeviceVar(name, opts) {
314
318
  return persistentVarFactory(name, fullOpts);
315
319
  }
316
320
  }
321
+ /** Create or reuse a {@link groupUserVar} observable for this name and context. */
317
322
  function groupUserVar(name, opts) {
318
323
  const fullOpts = { ...opts, scope: "groupUser" };
319
324
  if (opts?.defaultValue !== undefined) {
@@ -416,12 +421,21 @@ function persistentVarFactory(name, opts) {
416
421
  if (!dbRec) {
417
422
  dbRec = await migrateLegacyRec(table, name);
418
423
  }
419
- if (dbRec) {
420
- if (dbRec.scope !== scope) {
421
- console.warn(`${name}: deleting old persistent var record because scopes don't match. If this is unexpected there could be two different persistent variables using the same name`);
422
- await table.delete(dbRec);
423
- dbRec = undefined;
424
- }
424
+ // Preserve an existing secret unless the caller explicitly changes that setting.
425
+ if (dbRec?.isSecret !== undefined && opts.isSecret === undefined) {
426
+ isSecret = dbRec.isSecret;
427
+ }
428
+ if (dbRec && dbRec.scope !== scope) {
429
+ console.warn(`${name}: resetting the persistent var to its default under scope ${scope} (it was stored as ${dbRec.scope}). If this is unexpected there could be two different persistent variables using the same name`);
430
+ // Re-save under the new scope rather than deleting: names share one ID across scopes,
431
+ // and change tracking turns the move into a tombstone (to a device scope, which isn't
432
+ // tracked) or a fresh root snapshot (back to a synced scope) that peers apply.
433
+ dbRec = await table.save({
434
+ ...dbRec,
435
+ scope,
436
+ isSecret,
437
+ value: { value: defaultValue },
438
+ });
425
439
  }
426
440
  if (dbRec && !(0, utils_1.isDeterministicPvarId)(dbRec.persistentVarId)) {
427
441
  dbRec = await table.migrateLegacyId(dbRec);
@@ -503,6 +517,10 @@ function persistentVarFactory(name, opts) {
503
517
  rec = dbRec;
504
518
  }
505
519
  if (dbRec.persistentVarId === rec?.persistentVarId) {
520
+ // A later declaration of this scope must reload and perform its own transition.
521
+ if (dbRec.scope !== scope && pvarCache.get(cacheKey) === persistentVar) {
522
+ pvarCache.delete(cacheKey);
523
+ }
506
524
  if (evt.data.op === "delete") {
507
525
  rec = undefined;
508
526
  if (defaultValue !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.27.1",
3
+ "version": "0.27.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"