@peers-app/peers-sdk 0.27.0 → 0.27.2
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/dist/contracts/__tests__/tool-catalog.test.js +272 -6
- package/dist/contracts/tool-catalog.d.ts +5 -3
- package/dist/contracts/tool-catalog.js +86 -10
- package/dist/data/create-group.js +1 -1
- package/dist/data/group-members.d.ts +14 -0
- package/dist/data/group-members.js +36 -0
- package/dist/data/group-members.test.js +49 -0
- package/dist/invites/group-approval.js +4 -2
- package/dist/invites/group-approval.test.js +2 -0
- package/dist/utils.d.ts +21 -0
- package/dist/utils.js +35 -2
- package/dist/utils.test.js +36 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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 -
|
|
34
|
-
*
|
|
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 -
|
|
108
|
-
*
|
|
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
|
-
|
|
123
|
-
|
|
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 &&
|
|
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
|
-
|
|
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);
|
|
@@ -51,7 +51,7 @@ async function createGroup(userContext, input) {
|
|
|
51
51
|
});
|
|
52
52
|
await (0, groups_1.Groups)(groupDataContext).signAndSave(group);
|
|
53
53
|
await (0, group_members_1.GroupMembers)(groupDataContext).signAndSave({
|
|
54
|
-
groupMemberId: (0, utils_1.
|
|
54
|
+
groupMemberId: (0, utils_1.deterministicGroupMemberId)(groupId, me.userId),
|
|
55
55
|
groupId,
|
|
56
56
|
userId: me.userId,
|
|
57
57
|
role: group_member_roles_1.GroupMemberRole.Founder,
|
|
@@ -36,6 +36,20 @@ export declare class GroupMembersTable extends Table<IGroupMember> {
|
|
|
36
36
|
save(groupMember: IGroupMember, opts?: ISaveOptions): Promise<IGroupMember>;
|
|
37
37
|
signAndSave(groupMember: IGroupMember, opts?: ISaveOptions): Promise<IGroupMember>;
|
|
38
38
|
delete(groupMemberId: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Settle two membership rows for the same user in the same group: devices that approved the
|
|
41
|
+
* same join at once could each write one, under a different id.
|
|
42
|
+
*
|
|
43
|
+
* Merged only when both rows grant the same access (role and deleted state), so nothing about
|
|
44
|
+
* the member's access changes. The id {@link deterministicGroupMemberId} gives wins if either
|
|
45
|
+
* row has it, otherwise the lexicographically smaller id; the winner is kept as it is, signature
|
|
46
|
+
* included. Rows that disagree stay queued for a person. A pure function of the two rows, so
|
|
47
|
+
* every device keeps the same one.
|
|
48
|
+
*/
|
|
49
|
+
resolveUniqueConflict(incoming: IGroupMember, existing: IGroupMember): {
|
|
50
|
+
winner: IGroupMember;
|
|
51
|
+
loserId: string;
|
|
52
|
+
} | undefined;
|
|
39
53
|
/** Registers the signer for all SDK copies in this JavaScript realm. Replaces any prior signer. */
|
|
40
54
|
static enableGroupMemberSigning(fn: (groupMember: IGroupMember) => IGroupMember): void;
|
|
41
55
|
/** @deprecated Forbidden on GroupsTable; use save() */
|
|
@@ -40,6 +40,7 @@ const zod_1 = require("zod");
|
|
|
40
40
|
const user_context_singleton_1 = require("../context/user-context-singleton");
|
|
41
41
|
const decorators_1 = require("../data/orm/decorators");
|
|
42
42
|
const zod_types_1 = require("../types/zod-types");
|
|
43
|
+
const utils_1 = require("../utils");
|
|
43
44
|
const group_member_roles_1 = require("./group-member-roles");
|
|
44
45
|
const group_permissions_1 = require("./group-permissions");
|
|
45
46
|
const table_1 = require("./orm/table");
|
|
@@ -125,6 +126,41 @@ let GroupMembersTable = (() => {
|
|
|
125
126
|
await this.signAndSave(groupMember);
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Settle two membership rows for the same user in the same group: devices that approved the
|
|
131
|
+
* same join at once could each write one, under a different id.
|
|
132
|
+
*
|
|
133
|
+
* Merged only when both rows grant the same access (role and deleted state), so nothing about
|
|
134
|
+
* the member's access changes. The id {@link deterministicGroupMemberId} gives wins if either
|
|
135
|
+
* row has it, otherwise the lexicographically smaller id; the winner is kept as it is, signature
|
|
136
|
+
* included. Rows that disagree stay queued for a person. A pure function of the two rows, so
|
|
137
|
+
* every device keeps the same one.
|
|
138
|
+
*/
|
|
139
|
+
resolveUniqueConflict(incoming, existing) {
|
|
140
|
+
if (incoming.groupId !== existing.groupId || incoming.userId !== existing.userId) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
if (incoming.groupMemberId === existing.groupMemberId) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (incoming.role !== existing.role ||
|
|
147
|
+
Boolean(incoming.deleted) !== Boolean(existing.deleted)) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const derivedId = (0, utils_1.deterministicGroupMemberId)(incoming.groupId, incoming.userId);
|
|
151
|
+
let winner;
|
|
152
|
+
if (incoming.groupMemberId === derivedId) {
|
|
153
|
+
winner = incoming;
|
|
154
|
+
}
|
|
155
|
+
else if (existing.groupMemberId === derivedId) {
|
|
156
|
+
winner = existing;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
winner = incoming.groupMemberId < existing.groupMemberId ? incoming : existing;
|
|
160
|
+
}
|
|
161
|
+
const loser = winner === incoming ? existing : incoming;
|
|
162
|
+
return { winner, loserId: loser.groupMemberId };
|
|
163
|
+
}
|
|
128
164
|
/** Registers the signer for all SDK copies in this JavaScript realm. Replaces any prior signer. */
|
|
129
165
|
static enableGroupMemberSigning(fn) {
|
|
130
166
|
signingGlobals[signingKey] = fn;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const keys_1 = require("../keys");
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const group_members_1 = require("./group-members");
|
|
4
6
|
const group_permissions_1 = require("./group-permissions");
|
|
5
7
|
// Test the group members signature logic directly without ORM dependencies
|
|
6
8
|
describe("Group Members Signature Logic", () => {
|
|
@@ -285,3 +287,50 @@ describe("Group Members Signature Logic", () => {
|
|
|
285
287
|
});
|
|
286
288
|
});
|
|
287
289
|
});
|
|
290
|
+
describe("GroupMembersTable.resolveUniqueConflict", () => {
|
|
291
|
+
const groupId = (0, utils_1.newid)();
|
|
292
|
+
const userId = (0, utils_1.newid)();
|
|
293
|
+
const resolve = (incoming, existing) => group_members_1.GroupMembersTable.prototype.resolveUniqueConflict.call({}, incoming, existing);
|
|
294
|
+
const row = (groupMemberId, overrides = {}) => ({
|
|
295
|
+
groupMemberId,
|
|
296
|
+
groupId,
|
|
297
|
+
userId,
|
|
298
|
+
role: group_permissions_1.GroupMemberRole.Writer,
|
|
299
|
+
signature: `sig-${groupMemberId}`,
|
|
300
|
+
...overrides,
|
|
301
|
+
});
|
|
302
|
+
it("keeps the smaller id, unchanged, whichever row arrives", () => {
|
|
303
|
+
const [low, high] = [(0, utils_1.newid)(), (0, utils_1.newid)()].sort();
|
|
304
|
+
const a = row(low);
|
|
305
|
+
const b = row(high);
|
|
306
|
+
expect(resolve(a, b)).toEqual({ winner: a, loserId: high });
|
|
307
|
+
expect(resolve(b, a)).toEqual({ winner: a, loserId: high });
|
|
308
|
+
expect(resolve(b, a)?.winner.signature).toBe(`sig-${low}`);
|
|
309
|
+
});
|
|
310
|
+
it("prefers the id derived from the group and user", () => {
|
|
311
|
+
const derived = row((0, utils_1.deterministicGroupMemberId)(groupId, userId));
|
|
312
|
+
const other = row("00000000000000000000000001");
|
|
313
|
+
expect(resolve(other, derived)).toEqual({ winner: derived, loserId: other.groupMemberId });
|
|
314
|
+
expect(resolve(derived, other)).toEqual({ winner: derived, loserId: other.groupMemberId });
|
|
315
|
+
});
|
|
316
|
+
it("leaves rows that grant different access for a person to settle", () => {
|
|
317
|
+
const a = row((0, utils_1.newid)());
|
|
318
|
+
expect(resolve(a, row((0, utils_1.newid)(), { role: group_permissions_1.GroupMemberRole.Admin }))).toBeUndefined();
|
|
319
|
+
expect(resolve(a, row((0, utils_1.newid)(), { deleted: true }))).toBeUndefined();
|
|
320
|
+
expect(resolve(a, row((0, utils_1.newid)(), { userId: (0, utils_1.newid)() }))).toBeUndefined();
|
|
321
|
+
expect(resolve(a, row((0, utils_1.newid)(), { groupId: (0, utils_1.newid)() }))).toBeUndefined();
|
|
322
|
+
expect(resolve(a, { ...a })).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
it("treats a missing deleted flag as not deleted", () => {
|
|
325
|
+
const a = row((0, utils_1.newid)());
|
|
326
|
+
const b = row((0, utils_1.newid)(), { deleted: false });
|
|
327
|
+
expect(resolve(a, b)).toBeDefined();
|
|
328
|
+
});
|
|
329
|
+
it("depends only on the two signed rows and never modifies either one", () => {
|
|
330
|
+
const a = Object.freeze(row((0, utils_1.newid)()));
|
|
331
|
+
const b = Object.freeze(row((0, utils_1.newid)()));
|
|
332
|
+
const before = [structuredClone(a), structuredClone(b)];
|
|
333
|
+
expect(resolve(a, b)).toEqual(resolve(b, a));
|
|
334
|
+
expect([a, b]).toEqual(before);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -31,7 +31,7 @@ async function buildGroupApproval(userContext, input) {
|
|
|
31
31
|
// Groups created before founders got an explicit membership row: the founder role is
|
|
32
32
|
// implicit (see `getUserRole`). Write the row now so it seeds to joiners like any admin.
|
|
33
33
|
approvingMember = await groupMembersTable.signAndSave({
|
|
34
|
-
groupMemberId: (0, utils_1.
|
|
34
|
+
groupMemberId: (0, utils_1.deterministicGroupMemberId)(input.groupId, userContext.userId),
|
|
35
35
|
groupId: input.groupId,
|
|
36
36
|
userId: userContext.userId,
|
|
37
37
|
role: data_1.GroupMemberRole.Founder,
|
|
@@ -65,8 +65,10 @@ async function buildGroupApproval(userContext, input) {
|
|
|
65
65
|
membership = await groupMembersTable.signAndSave(existingMember);
|
|
66
66
|
}
|
|
67
67
|
else {
|
|
68
|
+
// The same id on each of the approver's devices: a join several of them approve at once
|
|
69
|
+
// is one row, not rows that collide on (groupId, userId).
|
|
68
70
|
membership = await groupMembersTable.signAndSave({
|
|
69
|
-
groupMemberId: (0, utils_1.
|
|
71
|
+
groupMemberId: (0, utils_1.deterministicGroupMemberId)(input.groupId, input.joiner.userId),
|
|
70
72
|
groupId: input.groupId,
|
|
71
73
|
userId: input.joiner.userId,
|
|
72
74
|
role: input.role,
|
|
@@ -149,6 +149,8 @@ describe("group approval payloads", () => {
|
|
|
149
149
|
role: group_member_roles_1.GroupMemberRole.Writer,
|
|
150
150
|
});
|
|
151
151
|
expect(approval.membership.signature).toContain(":");
|
|
152
|
+
// Each of the approver's devices writes the same row for this join.
|
|
153
|
+
expect(approval.membership.groupMemberId).toBe((0, utils_1.deterministicGroupMemberId)(group.groupId, joiner.userId));
|
|
152
154
|
expect(approval.seed.groupMembers.map((member) => member.userId)).toContain(admin.userId);
|
|
153
155
|
expect(approval.seed.devices[0]).toMatchObject({
|
|
154
156
|
deviceId: admin.deviceId,
|
package/dist/utils.d.ts
CHANGED
|
@@ -37,6 +37,23 @@ export declare function deterministicTableId(identity: string): string;
|
|
|
37
37
|
* @param id Peer ID to inspect.
|
|
38
38
|
*/
|
|
39
39
|
export declare function isDeterministicTableId(id: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The ID of a user's membership row in a group, the same on every device.
|
|
42
|
+
*
|
|
43
|
+
* Several of an admin's devices can approve the same join at once; with this ID they write one
|
|
44
|
+
* row, which merges as an ordinary update, instead of rows that collide on the
|
|
45
|
+
* `(groupId, userId)` unique index.
|
|
46
|
+
*
|
|
47
|
+
* @param groupId The group.
|
|
48
|
+
* @param userId The member.
|
|
49
|
+
*/
|
|
50
|
+
export declare function deterministicGroupMemberId(groupId: string, userId: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Return whether a peer ID was generated by {@link deterministicGroupMemberId}.
|
|
53
|
+
*
|
|
54
|
+
* @param id Peer ID to inspect.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isDeterministicGroupMemberId(id: string): boolean;
|
|
40
57
|
/** Resolves after `ms` milliseconds (0 yields a microtask-yield on most runtimes). */
|
|
41
58
|
export declare function sleep(ms?: number): Promise<void>;
|
|
42
59
|
/** Turns `fooBar` into `Foo Bar` for labels (also replaces `_` with space). */
|
|
@@ -93,6 +110,8 @@ export declare function getTimestamp(): number;
|
|
|
93
110
|
* @param opts.timeout Clamped timeout; defaults to {@link PeerDeviceConsts} min/max
|
|
94
111
|
* @param opts.retriesOnError Max error retries
|
|
95
112
|
* @param opts.retriesOnTimeout Max “timed out” races before giving up
|
|
113
|
+
* @param opts.shouldRetry Return false for a terminal error that must be reported immediately.
|
|
114
|
+
* @param opts.logFailure Log exhausted error retries; defaults to `console.error`.
|
|
96
115
|
* @throws Last error, timeout exhaustion, or if the connection is closed
|
|
97
116
|
*/
|
|
98
117
|
export declare function retryOnErrorOrTimeout<T>({ fn, connection, opts, }: {
|
|
@@ -102,6 +121,8 @@ export declare function retryOnErrorOrTimeout<T>({ fn, connection, opts, }: {
|
|
|
102
121
|
timeout?: number;
|
|
103
122
|
retriesOnError?: number;
|
|
104
123
|
retriesOnTimeout?: number;
|
|
124
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
125
|
+
logFailure?: (message: string, error: unknown) => void;
|
|
105
126
|
};
|
|
106
127
|
}): Promise<T>;
|
|
107
128
|
/**
|
package/dist/utils.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.deterministicPvarId = deterministicPvarId;
|
|
|
7
7
|
exports.isDeterministicPvarId = isDeterministicPvarId;
|
|
8
8
|
exports.deterministicTableId = deterministicTableId;
|
|
9
9
|
exports.isDeterministicTableId = isDeterministicTableId;
|
|
10
|
+
exports.deterministicGroupMemberId = deterministicGroupMemberId;
|
|
11
|
+
exports.isDeterministicGroupMemberId = isDeterministicGroupMemberId;
|
|
10
12
|
exports.sleep = sleep;
|
|
11
13
|
exports.camelCaseToSpaces = camelCaseToSpaces;
|
|
12
14
|
exports.camelCaseToHyphens = camelCaseToHyphens;
|
|
@@ -48,6 +50,8 @@ function simpleObjectHash(obj) {
|
|
|
48
50
|
const DETERMINISTIC_PVAR_PREFIX = "000pvar0";
|
|
49
51
|
/** Well-known prefix for deterministic package-table IDs. */
|
|
50
52
|
const DETERMINISTIC_TABLE_PREFIX = "000tbl00";
|
|
53
|
+
/** Well-known prefix for deterministic group-membership IDs. */
|
|
54
|
+
const DETERMINISTIC_GROUP_MEMBER_PREFIX = "000gmbr0";
|
|
51
55
|
/**
|
|
52
56
|
* Converts a byte array to a base-36 string of the requested length, using unbiased modular reduction.
|
|
53
57
|
* Bytes >= 252 are skipped to avoid bias (same technique as {@link cryptoRandomString}).
|
|
@@ -105,6 +109,27 @@ function deterministicTableId(identity) {
|
|
|
105
109
|
function isDeterministicTableId(id) {
|
|
106
110
|
return id.startsWith(DETERMINISTIC_TABLE_PREFIX);
|
|
107
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* The ID of a user's membership row in a group, the same on every device.
|
|
114
|
+
*
|
|
115
|
+
* Several of an admin's devices can approve the same join at once; with this ID they write one
|
|
116
|
+
* row, which merges as an ordinary update, instead of rows that collide on the
|
|
117
|
+
* `(groupId, userId)` unique index.
|
|
118
|
+
*
|
|
119
|
+
* @param groupId The group.
|
|
120
|
+
* @param userId The member.
|
|
121
|
+
*/
|
|
122
|
+
function deterministicGroupMemberId(groupId, userId) {
|
|
123
|
+
return deterministicPeerId(DETERMINISTIC_GROUP_MEMBER_PREFIX, `${groupId}:${userId}`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Return whether a peer ID was generated by {@link deterministicGroupMemberId}.
|
|
127
|
+
*
|
|
128
|
+
* @param id Peer ID to inspect.
|
|
129
|
+
*/
|
|
130
|
+
function isDeterministicGroupMemberId(id) {
|
|
131
|
+
return id.startsWith(DETERMINISTIC_GROUP_MEMBER_PREFIX);
|
|
132
|
+
}
|
|
108
133
|
/** Resolves after `ms` milliseconds (0 yields a microtask-yield on most runtimes). */
|
|
109
134
|
function sleep(ms = 0) {
|
|
110
135
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -243,6 +268,8 @@ function getTimestamp() {
|
|
|
243
268
|
* @param opts.timeout Clamped timeout; defaults to {@link PeerDeviceConsts} min/max
|
|
244
269
|
* @param opts.retriesOnError Max error retries
|
|
245
270
|
* @param opts.retriesOnTimeout Max “timed out” races before giving up
|
|
271
|
+
* @param opts.shouldRetry Return false for a terminal error that must be reported immediately.
|
|
272
|
+
* @param opts.logFailure Log exhausted error retries; defaults to `console.error`.
|
|
246
273
|
* @throws Last error, timeout exhaustion, or if the connection is closed
|
|
247
274
|
*/
|
|
248
275
|
async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
|
|
@@ -258,8 +285,8 @@ async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
|
|
|
258
285
|
let resultSet = false;
|
|
259
286
|
let finalError;
|
|
260
287
|
while (errorCount <= retriesOnError && timeoutCount <= retriesOnTimeout) {
|
|
288
|
+
let sleepTimeout = null;
|
|
261
289
|
try {
|
|
262
|
-
let sleepTimeout = null;
|
|
263
290
|
const sleepPromise = new Promise((resolve) => {
|
|
264
291
|
sleepTimeout = setTimeout(() => {
|
|
265
292
|
resolve();
|
|
@@ -292,14 +319,20 @@ async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
|
|
|
292
319
|
finalError = error;
|
|
293
320
|
connection.errorRate = connection.errorRate * 0.9 + 0.1;
|
|
294
321
|
errorCount++;
|
|
322
|
+
if (opts.shouldRetry?.(error) === false)
|
|
323
|
+
throw error;
|
|
295
324
|
if (connection.closed) {
|
|
296
325
|
throw new Error(`connection has been closed`);
|
|
297
326
|
}
|
|
298
327
|
}
|
|
328
|
+
finally {
|
|
329
|
+
if (sleepTimeout !== null)
|
|
330
|
+
clearTimeout(sleepTimeout);
|
|
331
|
+
}
|
|
299
332
|
console.warn(`Call failed, retrying: timeout is ${timeout}ms, errorCount: ${errorCount} of ${retriesOnError}, timeoutCount: ${timeoutCount} of ${retriesOnTimeout + 1}. Last error:`, finalError);
|
|
300
333
|
}
|
|
301
334
|
if (finalError) {
|
|
302
|
-
console.error(`Retry failed after ${errorCount} errors and ${timeoutCount} timeouts. Last error:`, finalError);
|
|
335
|
+
(opts.logFailure ?? console.error)(`Retry failed after ${errorCount} errors and ${timeoutCount} timeouts. Last error:`, finalError);
|
|
303
336
|
throw finalError;
|
|
304
337
|
}
|
|
305
338
|
else {
|
package/dist/utils.test.js
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const lodash_1 = require("lodash");
|
|
4
4
|
const utils_1 = require("./utils");
|
|
5
|
+
describe("retryOnErrorOrTimeout", () => {
|
|
6
|
+
it("reports a terminal error after one call and clears its timeout", async () => {
|
|
7
|
+
jest.useFakeTimers();
|
|
8
|
+
const error = new Error("No handler registered for event: listChanges_group");
|
|
9
|
+
const fn = jest.fn().mockRejectedValue(error);
|
|
10
|
+
try {
|
|
11
|
+
await expect((0, utils_1.retryOnErrorOrTimeout)({
|
|
12
|
+
fn,
|
|
13
|
+
connection: { deviceId: (0, utils_1.newid)(), latencyMs: 1, errorRate: 0, timestampLastApplied: 0 },
|
|
14
|
+
opts: { shouldRetry: () => false },
|
|
15
|
+
})).rejects.toBe(error);
|
|
16
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
17
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
jest.useRealTimers();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
5
24
|
describe("readResponseTextCapped", () => {
|
|
6
25
|
it("returns a body within the cap and refuses one over it", async () => {
|
|
7
26
|
await expect((0, utils_1.readResponseTextCapped)(new Response("hello"), 5)).resolves.toBe("hello");
|
|
@@ -132,6 +151,23 @@ describe("newid", () => {
|
|
|
132
151
|
});
|
|
133
152
|
});
|
|
134
153
|
});
|
|
154
|
+
describe("deterministicGroupMemberId", () => {
|
|
155
|
+
it("preserves the membership identity protocol across SDK versions", () => {
|
|
156
|
+
expect((0, utils_1.deterministicGroupMemberId)("00000000000000000000000001", "00000000000000000000000002")).toBe("000gmbr0jzkpw7vuc2ew84u8c");
|
|
157
|
+
});
|
|
158
|
+
it("is a stable peer id per group and user", () => {
|
|
159
|
+
const groupId = (0, utils_1.newid)();
|
|
160
|
+
const userId = (0, utils_1.newid)();
|
|
161
|
+
const id = (0, utils_1.deterministicGroupMemberId)(groupId, userId);
|
|
162
|
+
expect(id).toMatch(/^[a-z0-9]{25}$/);
|
|
163
|
+
expect((0, utils_1.isid)(id)).toBe(true);
|
|
164
|
+
expect((0, utils_1.isDeterministicGroupMemberId)(id)).toBe(true);
|
|
165
|
+
expect((0, utils_1.deterministicGroupMemberId)(groupId, userId)).toBe(id);
|
|
166
|
+
expect((0, utils_1.deterministicGroupMemberId)(groupId, (0, utils_1.newid)())).not.toBe(id);
|
|
167
|
+
expect((0, utils_1.deterministicGroupMemberId)(userId, groupId)).not.toBe(id);
|
|
168
|
+
expect((0, utils_1.isDeterministicGroupMemberId)((0, utils_1.newid)())).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
135
171
|
describe("debounceByArgs", () => {
|
|
136
172
|
beforeEach(() => {
|
|
137
173
|
jest.useFakeTimers();
|